@effect-uai/core 0.5.1 → 0.5.2

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.
@@ -0,0 +1,142 @@
1
+ import { ContentFiltered, RateLimited, Timeout, Unavailable } from "../domain/AiError.mjs";
2
+ import { effect, stream } from "./Retry.mjs";
3
+ import { i as it, n as globalExpect, r as describe } from "../dist-DV5ISja1.mjs";
4
+ import { Cause, Effect, Exit, Option, Ref, Schedule, Stream } from "effect";
5
+ //#region src/retry/Retry.test.ts
6
+ const planned = (attempts, plan) => Stream.unwrap(Ref.getAndUpdate(attempts, (n) => n + 1).pipe(Effect.map((n) => plan[Math.min(n, plan.length - 1)])));
7
+ const plannedEffect = (attempts, plan) => Ref.getAndUpdate(attempts, (n) => n + 1).pipe(Effect.flatMap((n) => plan[Math.min(n, plan.length - 1)]));
8
+ describe("Retry.stream", () => {
9
+ it("retries on RateLimited and yields success on the next attempt", async () => {
10
+ const program = Effect.gen(function* () {
11
+ const attempts = yield* Ref.make(0);
12
+ const s = planned(attempts, [Stream.fail(new RateLimited({
13
+ provider: "mock",
14
+ raw: null
15
+ })), Stream.fromIterable(["a", "b"])]).pipe(stream(Schedule.recurs(3)));
16
+ const out = yield* Stream.runCollect(s);
17
+ const count = yield* Ref.get(attempts);
18
+ return {
19
+ out: Array.from(out),
20
+ count
21
+ };
22
+ });
23
+ const { out, count } = await Effect.runPromise(program);
24
+ globalExpect(count).toBe(2);
25
+ globalExpect(out).toEqual(["a", "b"]);
26
+ });
27
+ it("surfaces the underlying retryable failure when retries are exhausted", async () => {
28
+ const cause = new Unavailable({
29
+ provider: "mock",
30
+ raw: null
31
+ });
32
+ const s = Stream.fail(cause).pipe(stream(Schedule.recurs(2)));
33
+ const exit = await Effect.runPromise(Effect.exit(Stream.runCollect(s)));
34
+ globalExpect(Exit.isFailure(exit)).toBe(true);
35
+ if (Exit.isFailure(exit)) globalExpect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause));
36
+ });
37
+ it("bypasses retry for non-retryable AiError (ContentFiltered)", async () => {
38
+ const program = Effect.gen(function* () {
39
+ const attempts = yield* Ref.make(0);
40
+ const cause = new ContentFiltered({
41
+ provider: "mock",
42
+ raw: null
43
+ });
44
+ const s = planned(attempts, [Stream.fail(cause)]).pipe(stream(Schedule.recurs(5)));
45
+ return {
46
+ exit: yield* Effect.exit(Stream.runCollect(s)),
47
+ count: yield* Ref.get(attempts),
48
+ cause
49
+ };
50
+ });
51
+ const { exit, count, cause } = await Effect.runPromise(program);
52
+ globalExpect(count).toBe(1);
53
+ globalExpect(Exit.isFailure(exit)).toBe(true);
54
+ if (Exit.isFailure(exit)) globalExpect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause));
55
+ });
56
+ it("preserves items emitted before a retryable failure (and replays on retry)", async () => {
57
+ const program = Effect.gen(function* () {
58
+ const s = planned(yield* Ref.make(0), [Stream.concat(Stream.succeed("partial"), Stream.fail(new Timeout({
59
+ provider: "mock",
60
+ raw: null
61
+ }))), Stream.fromIterable(["partial", "done"])]).pipe(stream(Schedule.recurs(1)));
62
+ const out = yield* Stream.runCollect(s);
63
+ return Array.from(out);
64
+ });
65
+ globalExpect(await Effect.runPromise(program)).toEqual([
66
+ "partial",
67
+ "partial",
68
+ "done"
69
+ ]);
70
+ });
71
+ });
72
+ describe("Retry.effect", () => {
73
+ it("retries on RateLimited and yields success on the next attempt", async () => {
74
+ const program = Effect.gen(function* () {
75
+ const attempts = yield* Ref.make(0);
76
+ return {
77
+ value: yield* plannedEffect(attempts, [Effect.fail(new RateLimited({
78
+ provider: "mock",
79
+ raw: null
80
+ })), Effect.succeed("ok")]).pipe(effect(Schedule.recurs(3))),
81
+ count: yield* Ref.get(attempts)
82
+ };
83
+ });
84
+ const { value, count } = await Effect.runPromise(program);
85
+ globalExpect(count).toBe(2);
86
+ globalExpect(value).toBe("ok");
87
+ });
88
+ it("surfaces the underlying retryable failure when retries are exhausted", async () => {
89
+ const cause = new Unavailable({
90
+ provider: "mock",
91
+ raw: null
92
+ });
93
+ const eff = Effect.fail(cause).pipe(effect(Schedule.recurs(2)));
94
+ const exit = await Effect.runPromise(Effect.exit(eff));
95
+ globalExpect(Exit.isFailure(exit)).toBe(true);
96
+ if (Exit.isFailure(exit)) globalExpect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause));
97
+ });
98
+ it("bypasses retry for non-retryable AiError (ContentFiltered)", async () => {
99
+ const program = Effect.gen(function* () {
100
+ const attempts = yield* Ref.make(0);
101
+ const cause = new ContentFiltered({
102
+ provider: "mock",
103
+ raw: null
104
+ });
105
+ return {
106
+ exit: yield* Effect.exit(plannedEffect(attempts, [Effect.fail(cause)]).pipe(effect(Schedule.recurs(5)))),
107
+ count: yield* Ref.get(attempts),
108
+ cause
109
+ };
110
+ });
111
+ const { exit, count, cause } = await Effect.runPromise(program);
112
+ globalExpect(count).toBe(1);
113
+ globalExpect(Exit.isFailure(exit)).toBe(true);
114
+ if (Exit.isFailure(exit)) globalExpect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause));
115
+ });
116
+ it("does NOT replay successful prior work — at-most-once semantics", async () => {
117
+ const program = Effect.gen(function* () {
118
+ const attempts = yield* Ref.make(0);
119
+ return {
120
+ value: yield* plannedEffect(attempts, [
121
+ Effect.fail(new Timeout({
122
+ provider: "mock",
123
+ raw: null
124
+ })),
125
+ Effect.fail(new Timeout({
126
+ provider: "mock",
127
+ raw: null
128
+ })),
129
+ Effect.succeed("done")
130
+ ]).pipe(effect(Schedule.recurs(5))),
131
+ count: yield* Ref.get(attempts)
132
+ };
133
+ });
134
+ const { value, count } = await Effect.runPromise(program);
135
+ globalExpect(count).toBe(3);
136
+ globalExpect(value).toBe("done");
137
+ });
138
+ });
139
+ //#endregion
140
+ export {};
141
+
142
+ //# sourceMappingURL=Retry.test.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Retry.test.mjs","names":["AiError.RateLimited","Retry.stream","AiError.Unavailable","AiError.ContentFiltered","AiError.Timeout","Retry.effect"],"sources":["../../src/retry/Retry.test.ts"],"sourcesContent":["import { Cause, Effect, Exit, Option, Ref, Schedule, Stream } from \"effect\"\nimport { describe, expect, it } from \"vitest\"\nimport * as AiError from \"../domain/AiError.js\"\nimport * as Retry from \"./Retry.js\"\n\n// Each call pulls the next plan entry; the last entry repeats forever\n// (so \"succeed on attempt N\" plans naturally re-yield success after).\nconst planned = <A, E>(\n attempts: Ref.Ref<number>,\n plan: ReadonlyArray<Stream.Stream<A, E>>,\n): Stream.Stream<A, E> =>\n Stream.unwrap(\n Ref.getAndUpdate(attempts, (n) => n + 1).pipe(\n Effect.map((n) => plan[Math.min(n, plan.length - 1)]!),\n ),\n )\n\nconst plannedEffect = <A, E>(\n attempts: Ref.Ref<number>,\n plan: ReadonlyArray<Effect.Effect<A, E>>,\n): Effect.Effect<A, E> =>\n Ref.getAndUpdate(attempts, (n) => n + 1).pipe(\n Effect.flatMap((n) => plan[Math.min(n, plan.length - 1)]!),\n )\n\ndescribe(\"Retry.stream\", () => {\n it(\"retries on RateLimited and yields success on the next attempt\", async () => {\n const program = Effect.gen(function* () {\n const attempts = yield* Ref.make(0)\n const s = planned(attempts, [\n Stream.fail<AiError.AiError>(new AiError.RateLimited({ provider: \"mock\", raw: null })),\n Stream.fromIterable([\"a\", \"b\"]),\n ]).pipe(Retry.stream(Schedule.recurs(3)))\n const out = yield* Stream.runCollect(s)\n const count = yield* Ref.get(attempts)\n return { out: Array.from(out), count }\n })\n\n const { out, count } = await Effect.runPromise(program)\n expect(count).toBe(2)\n expect(out).toEqual([\"a\", \"b\"])\n })\n\n it(\"surfaces the underlying retryable failure when retries are exhausted\", async () => {\n const cause = new AiError.Unavailable({ provider: \"mock\", raw: null })\n const s = Stream.fail<AiError.AiError>(cause).pipe(Retry.stream(Schedule.recurs(2)))\n\n const exit = await Effect.runPromise(Effect.exit(Stream.runCollect(s)))\n expect(Exit.isFailure(exit)).toBe(true)\n if (Exit.isFailure(exit)) {\n expect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause))\n }\n })\n\n it(\"bypasses retry for non-retryable AiError (ContentFiltered)\", async () => {\n const program = Effect.gen(function* () {\n const attempts = yield* Ref.make(0)\n const cause = new AiError.ContentFiltered({ provider: \"mock\", raw: null })\n const s = planned(attempts, [Stream.fail<AiError.AiError>(cause)]).pipe(\n Retry.stream(Schedule.recurs(5)),\n )\n const exit = yield* Effect.exit(Stream.runCollect(s))\n const count = yield* Ref.get(attempts)\n return { exit, count, cause }\n })\n\n const { exit, count, cause } = await Effect.runPromise(program)\n expect(count).toBe(1)\n expect(Exit.isFailure(exit)).toBe(true)\n if (Exit.isFailure(exit)) {\n expect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause))\n }\n })\n\n it(\"preserves items emitted before a retryable failure (and replays on retry)\", async () => {\n // Documents the 'replays on retry' caveat: the entire stream re-runs.\n const program = Effect.gen(function* () {\n const attempts = yield* Ref.make(0)\n const s = planned(attempts, [\n Stream.concat(\n Stream.succeed(\"partial\"),\n Stream.fail<AiError.AiError>(new AiError.Timeout({ provider: \"mock\", raw: null })),\n ),\n Stream.fromIterable([\"partial\", \"done\"]),\n ]).pipe(Retry.stream(Schedule.recurs(1)))\n const out = yield* Stream.runCollect(s)\n return Array.from(out)\n })\n\n const out = await Effect.runPromise(program)\n expect(out).toEqual([\"partial\", \"partial\", \"done\"])\n })\n})\n\ndescribe(\"Retry.effect\", () => {\n it(\"retries on RateLimited and yields success on the next attempt\", async () => {\n const program = Effect.gen(function* () {\n const attempts = yield* Ref.make(0)\n const value = yield* plannedEffect(attempts, [\n Effect.fail<AiError.AiError>(new AiError.RateLimited({ provider: \"mock\", raw: null })),\n Effect.succeed(\"ok\"),\n ]).pipe(Retry.effect(Schedule.recurs(3)))\n const count = yield* Ref.get(attempts)\n return { value, count }\n })\n\n const { value, count } = await Effect.runPromise(program)\n expect(count).toBe(2)\n expect(value).toBe(\"ok\")\n })\n\n it(\"surfaces the underlying retryable failure when retries are exhausted\", async () => {\n const cause = new AiError.Unavailable({ provider: \"mock\", raw: null })\n const eff = Effect.fail<AiError.AiError>(cause).pipe(Retry.effect(Schedule.recurs(2)))\n\n const exit = await Effect.runPromise(Effect.exit(eff))\n expect(Exit.isFailure(exit)).toBe(true)\n if (Exit.isFailure(exit)) {\n expect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause))\n }\n })\n\n it(\"bypasses retry for non-retryable AiError (ContentFiltered)\", async () => {\n const program = Effect.gen(function* () {\n const attempts = yield* Ref.make(0)\n const cause = new AiError.ContentFiltered({ provider: \"mock\", raw: null })\n const exit = yield* Effect.exit(\n plannedEffect(attempts, [Effect.fail<AiError.AiError>(cause)]).pipe(\n Retry.effect(Schedule.recurs(5)),\n ),\n )\n const count = yield* Ref.get(attempts)\n return { exit, count, cause }\n })\n\n const { exit, count, cause } = await Effect.runPromise(program)\n expect(count).toBe(1)\n expect(Exit.isFailure(exit)).toBe(true)\n if (Exit.isFailure(exit)) {\n expect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause))\n }\n })\n\n it(\"does NOT replay successful prior work — at-most-once semantics\", async () => {\n // Contrast with Retry.stream: an Effect.retry only re-invokes the\n // failing computation. No replay of work done before the failure\n // (there's nothing to replay — Effects are single-shot).\n const program = Effect.gen(function* () {\n const attempts = yield* Ref.make(0)\n const value = yield* plannedEffect(attempts, [\n Effect.fail<AiError.AiError>(new AiError.Timeout({ provider: \"mock\", raw: null })),\n Effect.fail<AiError.AiError>(new AiError.Timeout({ provider: \"mock\", raw: null })),\n Effect.succeed(\"done\"),\n ]).pipe(Retry.effect(Schedule.recurs(5)))\n const count = yield* Ref.get(attempts)\n return { value, count }\n })\n\n const { value, count } = await Effect.runPromise(program)\n expect(count).toBe(3)\n expect(value).toBe(\"done\")\n })\n})\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"MockProvider.d.mts","names":[],"sources":["../../src/testing/MockProvider.ts"],"mappings":";;;;;;KAMY,WAAA;;AAAZ;;;;WAMW,aAAA,GAAgB,QAAA,CAAS,KAAA;AAAA;AAAA,KAGxB,IAAA;EAAA,SACD,OAAA,EAAS,aAAA,CAAc,IAAA;EAAA,SACvB,IAAA,EAAM,IAAA;AAAA;;;;;;;KASL,YAAA;EAAA,SACD,KAAA,EAAO,aAAA,CAAc,IAAA;AAAA;;;;;cAqGnB,KAAA,GACX,aAAA,EAAe,aAAA,CAAc,IAAA,GAC7B,OAAA,GAAU,WAAA,KACT,KAAA,CAAM,KAAA,CAAM,aAAA;AAzGf;;;;AAAA,cAqHa,iBAAA,GACX,aAAA,EAAe,aAAA,CAAc,IAAA,GAC7B,OAAA,GAAU,WAAA;EAAA,SAED,KAAA,EAAO,KAAA,CAAM,KAAA,CAAM,aAAA;EAAA,SACnB,QAAA,EAAU,MAAA,CAAO,MAAA,CAAO,YAAA;AAAA;;AApBnC;;;;;cAwCa,IAAA,GACX,aAAA,EAAe,aAAA,CAAc,IAAA,GAC7B,OAAA,GAAU,WAAA;EAAA,SAED,OAAA,EAAS,oBAAA;EAAA,SACT,QAAA,EAAU,MAAA,CAAO,MAAA,CAAO,YAAA;AAAA"}
1
+ {"version":3,"file":"MockProvider.d.mts","names":[],"sources":["../../src/testing/MockProvider.ts"],"mappings":";;;;;;KAUY,WAAA;;AAAZ;;;;WAMW,aAAA,GAAgB,QAAA,CAAS,KAAA;AAAA;AAAA,KAGxB,IAAA;EAAA,SACD,OAAA,EAAS,aAAA,CAAc,IAAA;EAAA,SACvB,IAAA,EAAM,IAAA;AAAA;;;;;;;KASL,YAAA;EAAA,SACD,KAAA,EAAO,aAAA,CAAc,IAAA;AAAA;;;;;cAsGnB,KAAA,GACX,aAAA,EAAe,aAAA,CAAc,IAAA,GAC7B,OAAA,GAAU,WAAA,KACT,KAAA,CAAM,KAAA,CAAM,aAAA;AA1Gf;;;;AAAA,cAsHa,iBAAA,GACX,aAAA,EAAe,aAAA,CAAc,IAAA,GAC7B,OAAA,GAAU,WAAA;EAAA,SAED,KAAA,EAAO,KAAA,CAAM,KAAA,CAAM,aAAA;EAAA,SACnB,QAAA,EAAU,MAAA,CAAO,MAAA,CAAO,YAAA;AAAA;;AApBnC;;;;;cAwCa,IAAA,GACX,aAAA,EAAe,aAAA,CAAc,IAAA,GAC7B,OAAA,GAAU,WAAA;EAAA,SAED,OAAA,EAAS,oBAAA;EAAA,SACT,QAAA,EAAU,MAAA,CAAO,MAAA,CAAO,YAAA;AAAA"}
@@ -1,7 +1,7 @@
1
1
  import { InvalidRequest } from "../domain/AiError.mjs";
2
2
  import { isOutputText } from "../domain/Items.mjs";
3
3
  import { TurnEvent } from "../domain/Turn.mjs";
4
- import { LanguageModel } from "../language-model/LanguageModel.mjs";
4
+ import { LanguageModel, turnFromStream } from "../language-model/LanguageModel.mjs";
5
5
  import { Array, Effect, Layer, Match, Option, Ref, Schedule, Stream } from "effect";
6
6
  //#region src/testing/MockProvider.ts
7
7
  const itemToDeltas = Match.type().pipe(Match.discriminators("type")({
@@ -29,13 +29,19 @@ const exhausted = (n, attempt) => new InvalidRequest({
29
29
  raw: `MockProvider exhausted: ${n} turns scripted, but call ${attempt} was made`
30
30
  });
31
31
  const noRecord = (_) => Effect.void;
32
- const buildService = (scriptedTurns, options, cursor, record) => ({ streamTurn: (request) => Stream.unwrap(Ref.getAndUpdate(cursor, (n) => n + 1).pipe(Effect.flatMap((i) => Option.match(Array.get(scriptedTurns, i), {
33
- onNone: () => Effect.succeed(Stream.fail(exhausted(scriptedTurns.length, i + 1))),
34
- onSome: (turn) => record({
35
- history: request.history,
36
- turn
37
- }).pipe(Effect.as(pacedDeltas(turn, options)))
38
- })))) });
32
+ const buildService = (scriptedTurns, options, cursor, record) => {
33
+ const streamTurn = (request) => Stream.unwrap(Ref.getAndUpdate(cursor, (n) => n + 1).pipe(Effect.flatMap((i) => Option.match(Array.get(scriptedTurns, i), {
34
+ onNone: () => Effect.succeed(Stream.fail(exhausted(scriptedTurns.length, i + 1))),
35
+ onSome: (turn) => record({
36
+ history: request.history,
37
+ turn
38
+ }).pipe(Effect.as(pacedDeltas(turn, options)))
39
+ }))));
40
+ return {
41
+ streamTurn,
42
+ turn: turnFromStream(streamTurn)
43
+ };
44
+ };
39
45
  const makeRecorderUnsafe = () => {
40
46
  const ref = Ref.makeUnsafe([]);
41
47
  return {
@@ -1 +1 @@
1
- {"version":3,"file":"MockProvider.mjs","names":["AiError.InvalidRequest","Arr"],"sources":["../../src/testing/MockProvider.ts"],"sourcesContent":["import { Array as Arr, Duration, Effect, Layer, Match, Option, Ref, Schedule, Stream } from \"effect\"\nimport * as AiError from \"../domain/AiError.js\"\nimport { type Item, isOutputText } from \"../domain/Items.js\"\nimport { LanguageModel, type LanguageModelService } from \"../language-model/LanguageModel.js\"\nimport { type Turn, TurnEvent } from \"../domain/Turn.js\"\n\nexport type MockOptions = {\n /**\n * If set, deltas of each scripted turn are spaced by this duration via\n * `Schedule.spaced`. Combine with `TestClock.adjust` for deterministic\n * timing in tests.\n */\n readonly deltaInterval?: Duration.Input\n}\n\nexport type Call = {\n readonly history: ReadonlyArray<Item>\n readonly turn: Turn\n}\n\n/**\n * A scripted mock provider. Pre-canned `Turn` outputs are returned in order,\n * one per call to `streamTurn`. Each scripted turn is split into synthetic\n * deltas (text → ToolCallStart → ToolCallArgsDelta → ... → TurnComplete)\n * so streaming consumers can see realistic delta shapes.\n */\nexport type MockRecorder = {\n readonly calls: ReadonlyArray<Call>\n}\n\n// ---------------------------------------------------------------------------\n// Pure projection: Turn → ReadonlyArray<TurnEvent>\n// ---------------------------------------------------------------------------\n\nconst itemToDeltas: (item: Item) => ReadonlyArray<TurnEvent> = Match.type<Item>().pipe(\n Match.discriminators(\"type\")({\n message: (m): ReadonlyArray<TurnEvent> =>\n m.role === \"assistant\"\n ? m.content.filter(isOutputText).map((b) => TurnEvent.TextDelta({ text: b.text }))\n : [],\n function_call: (fc) => [\n TurnEvent.ToolCallStart({ call_id: fc.call_id, name: fc.name }),\n TurnEvent.ToolCallArgsDelta({ call_id: fc.call_id, delta: fc.arguments }),\n ],\n function_call_output: () => [],\n reasoning: (r) =>\n r.summary !== undefined\n ? [TurnEvent.ReasoningDelta({ text: r.summary, kind: \"summary\" as const })]\n : [],\n }),\n Match.exhaustive,\n)\n\nconst turnToDeltas = (turn: Turn): ReadonlyArray<TurnEvent> => [\n ...turn.items.flatMap(itemToDeltas),\n TurnEvent.TurnComplete({ turn }),\n]\n\nconst pacedDeltas = (turn: Turn, options?: MockOptions): Stream.Stream<TurnEvent> => {\n const base = Stream.fromIterable(turnToDeltas(turn))\n return options?.deltaInterval === undefined\n ? base\n : base.pipe(Stream.schedule(Schedule.spaced(options.deltaInterval)))\n}\n\n// ---------------------------------------------------------------------------\n// Canonical service factory. One implementation; sync/Layer/recorder\n// variants below are just different ways to wire the cursor + record hook.\n// ---------------------------------------------------------------------------\n\nconst exhausted = (n: number, attempt: number): AiError.AiError =>\n new AiError.InvalidRequest({\n provider: \"mock\",\n raw: `MockProvider exhausted: ${n} turns scripted, but call ${attempt} was made`,\n })\n\nconst noRecord = (_: Call): Effect.Effect<void> => Effect.void\n\nconst buildService = (\n scriptedTurns: ReadonlyArray<Turn>,\n options: MockOptions | undefined,\n cursor: Ref.Ref<number>,\n record: (call: Call) => Effect.Effect<void>,\n): LanguageModelService => ({\n streamTurn: (request) =>\n Stream.unwrap(\n Ref.getAndUpdate(cursor, (n) => n + 1).pipe(\n Effect.flatMap(\n (i): Effect.Effect<Stream.Stream<TurnEvent, AiError.AiError>> =>\n Option.match(Arr.get(scriptedTurns, i), {\n onNone: () => Effect.succeed(Stream.fail(exhausted(scriptedTurns.length, i + 1))),\n onSome: (turn) =>\n record({ history: request.history, turn }).pipe(\n Effect.as(pacedDeltas(turn, options)),\n ),\n }),\n ),\n ),\n ),\n})\n\n// ---------------------------------------------------------------------------\n// Recorder handle. Unsafe Ref is local: it backs both the `record` write\n// hook (called inside the service) and the `recorder` read effect (called\n// by the test). Both close over the same cell.\n// ---------------------------------------------------------------------------\n\ntype RecorderHandle = {\n readonly record: (call: Call) => Effect.Effect<void>\n readonly recorder: Effect.Effect<MockRecorder>\n}\n\nconst makeRecorderUnsafe = (): RecorderHandle => {\n const ref = Ref.makeUnsafe<ReadonlyArray<Call>>([])\n return {\n record: (call) => Ref.update(ref, Arr.append(call)),\n recorder: Ref.get(ref).pipe(Effect.map((calls): MockRecorder => ({ calls }))),\n }\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Layer that registers a `MockProvider` against the `LanguageModel` tag.\n * Calls beyond the scripted turn count fail with `InvalidRequest`.\n */\nexport const layer = (\n scriptedTurns: ReadonlyArray<Turn>,\n options?: MockOptions,\n): Layer.Layer<LanguageModel> =>\n Layer.effect(\n LanguageModel,\n Ref.make(0).pipe(\n Effect.map((cursor) => buildService(scriptedTurns, options, cursor, noRecord)),\n ),\n )\n\n/**\n * Like `layer`, but also exposes a recorder that captures every call\n * (history + returned turn).\n */\nexport const layerWithRecorder = (\n scriptedTurns: ReadonlyArray<Turn>,\n options?: MockOptions,\n): {\n readonly layer: Layer.Layer<LanguageModel>\n readonly recorder: Effect.Effect<MockRecorder>\n} => {\n const { record, recorder } = makeRecorderUnsafe()\n return {\n layer: Layer.effect(\n LanguageModel,\n Ref.make(0).pipe(\n Effect.map((cursor) => buildService(scriptedTurns, options, cursor, record)),\n ),\n ),\n recorder,\n }\n}\n\n/**\n * Build the `LanguageModelService` value directly (no Layer), plus a\n * recorder. Use this when you want to swap models mid-program via\n * `Effect.provideService` instead of providing one model for the whole\n * program via `Layer`.\n */\nexport const make = (\n scriptedTurns: ReadonlyArray<Turn>,\n options?: MockOptions,\n): {\n readonly service: LanguageModelService\n readonly recorder: Effect.Effect<MockRecorder>\n} => {\n const cursor = Ref.makeUnsafe(0)\n const { record, recorder } = makeRecorderUnsafe()\n return {\n service: buildService(scriptedTurns, options, cursor, record),\n recorder,\n }\n}\n"],"mappings":";;;;;;AAkCA,MAAM,eAAyD,MAAM,MAAY,CAAC,KAChF,MAAM,eAAe,OAAO,CAAC;CAC3B,UAAU,MACR,EAAE,SAAS,cACP,EAAE,QAAQ,OAAO,aAAa,CAAC,KAAK,MAAM,UAAU,UAAU,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,GAChF,EAAE;CACR,gBAAgB,OAAO,CACrB,UAAU,cAAc;EAAE,SAAS,GAAG;EAAS,MAAM,GAAG;EAAM,CAAC,EAC/D,UAAU,kBAAkB;EAAE,SAAS,GAAG;EAAS,OAAO,GAAG;EAAW,CAAC,CAC1E;CACD,4BAA4B,EAAE;CAC9B,YAAY,MACV,EAAE,YAAY,KAAA,IACV,CAAC,UAAU,eAAe;EAAE,MAAM,EAAE;EAAS,MAAM;EAAoB,CAAC,CAAC,GACzE,EAAE;CACT,CAAC,EACF,MAAM,WACP;AAED,MAAM,gBAAgB,SAAyC,CAC7D,GAAG,KAAK,MAAM,QAAQ,aAAa,EACnC,UAAU,aAAa,EAAE,MAAM,CAAC,CACjC;AAED,MAAM,eAAe,MAAY,YAAoD;CACnF,MAAM,OAAO,OAAO,aAAa,aAAa,KAAK,CAAC;AACpD,QAAO,SAAS,kBAAkB,KAAA,IAC9B,OACA,KAAK,KAAK,OAAO,SAAS,SAAS,OAAO,QAAQ,cAAc,CAAC,CAAC;;AAQxE,MAAM,aAAa,GAAW,YAC5B,IAAIA,eAAuB;CACzB,UAAU;CACV,KAAK,2BAA2B,EAAE,4BAA4B,QAAQ;CACvE,CAAC;AAEJ,MAAM,YAAY,MAAiC,OAAO;AAE1D,MAAM,gBACJ,eACA,SACA,QACA,YAC0B,EAC1B,aAAa,YACX,OAAO,OACL,IAAI,aAAa,SAAS,MAAM,IAAI,EAAE,CAAC,KACrC,OAAO,SACJ,MACC,OAAO,MAAMC,MAAI,IAAI,eAAe,EAAE,EAAE;CACtC,cAAc,OAAO,QAAQ,OAAO,KAAK,UAAU,cAAc,QAAQ,IAAI,EAAE,CAAC,CAAC;CACjF,SAAS,SACP,OAAO;EAAE,SAAS,QAAQ;EAAS;EAAM,CAAC,CAAC,KACzC,OAAO,GAAG,YAAY,MAAM,QAAQ,CAAC,CACtC;CACJ,CAAC,CACL,CACF,CACF,EACJ;AAaD,MAAM,2BAA2C;CAC/C,MAAM,MAAM,IAAI,WAAgC,EAAE,CAAC;AACnD,QAAO;EACL,SAAS,SAAS,IAAI,OAAO,KAAKA,MAAI,OAAO,KAAK,CAAC;EACnD,UAAU,IAAI,IAAI,IAAI,CAAC,KAAK,OAAO,KAAK,WAAyB,EAAE,OAAO,EAAE,CAAC;EAC9E;;;;;;AAWH,MAAa,SACX,eACA,YAEA,MAAM,OACJ,eACA,IAAI,KAAK,EAAE,CAAC,KACV,OAAO,KAAK,WAAW,aAAa,eAAe,SAAS,QAAQ,SAAS,CAAC,CAC/E,CACF;;;;;AAMH,MAAa,qBACX,eACA,YAIG;CACH,MAAM,EAAE,QAAQ,aAAa,oBAAoB;AACjD,QAAO;EACL,OAAO,MAAM,OACX,eACA,IAAI,KAAK,EAAE,CAAC,KACV,OAAO,KAAK,WAAW,aAAa,eAAe,SAAS,QAAQ,OAAO,CAAC,CAC7E,CACF;EACD;EACD;;;;;;;;AASH,MAAa,QACX,eACA,YAIG;CACH,MAAM,SAAS,IAAI,WAAW,EAAE;CAChC,MAAM,EAAE,QAAQ,aAAa,oBAAoB;AACjD,QAAO;EACL,SAAS,aAAa,eAAe,SAAS,QAAQ,OAAO;EAC7D;EACD"}
1
+ {"version":3,"file":"MockProvider.mjs","names":["AiError.InvalidRequest","Arr"],"sources":["../../src/testing/MockProvider.ts"],"sourcesContent":["import { Array as Arr, Duration, Effect, Layer, Match, Option, Ref, Schedule, Stream } from \"effect\"\nimport * as AiError from \"../domain/AiError.js\"\nimport { type Item, isOutputText } from \"../domain/Items.js\"\nimport {\n LanguageModel,\n type LanguageModelService,\n turnFromStream,\n} from \"../language-model/LanguageModel.js\"\nimport { type Turn, TurnEvent } from \"../domain/Turn.js\"\n\nexport type MockOptions = {\n /**\n * If set, deltas of each scripted turn are spaced by this duration via\n * `Schedule.spaced`. Combine with `TestClock.adjust` for deterministic\n * timing in tests.\n */\n readonly deltaInterval?: Duration.Input\n}\n\nexport type Call = {\n readonly history: ReadonlyArray<Item>\n readonly turn: Turn\n}\n\n/**\n * A scripted mock provider. Pre-canned `Turn` outputs are returned in order,\n * one per call to `streamTurn`. Each scripted turn is split into synthetic\n * deltas (text → ToolCallStart → ToolCallArgsDelta → ... → TurnComplete)\n * so streaming consumers can see realistic delta shapes.\n */\nexport type MockRecorder = {\n readonly calls: ReadonlyArray<Call>\n}\n\n// ---------------------------------------------------------------------------\n// Pure projection: Turn → ReadonlyArray<TurnEvent>\n// ---------------------------------------------------------------------------\n\nconst itemToDeltas: (item: Item) => ReadonlyArray<TurnEvent> = Match.type<Item>().pipe(\n Match.discriminators(\"type\")({\n message: (m): ReadonlyArray<TurnEvent> =>\n m.role === \"assistant\"\n ? m.content.filter(isOutputText).map((b) => TurnEvent.TextDelta({ text: b.text }))\n : [],\n function_call: (fc) => [\n TurnEvent.ToolCallStart({ call_id: fc.call_id, name: fc.name }),\n TurnEvent.ToolCallArgsDelta({ call_id: fc.call_id, delta: fc.arguments }),\n ],\n function_call_output: () => [],\n reasoning: (r) =>\n r.summary !== undefined\n ? [TurnEvent.ReasoningDelta({ text: r.summary, kind: \"summary\" as const })]\n : [],\n }),\n Match.exhaustive,\n)\n\nconst turnToDeltas = (turn: Turn): ReadonlyArray<TurnEvent> => [\n ...turn.items.flatMap(itemToDeltas),\n TurnEvent.TurnComplete({ turn }),\n]\n\nconst pacedDeltas = (turn: Turn, options?: MockOptions): Stream.Stream<TurnEvent> => {\n const base = Stream.fromIterable(turnToDeltas(turn))\n return options?.deltaInterval === undefined\n ? base\n : base.pipe(Stream.schedule(Schedule.spaced(options.deltaInterval)))\n}\n\n// ---------------------------------------------------------------------------\n// Canonical service factory. One implementation; sync/Layer/recorder\n// variants below are just different ways to wire the cursor + record hook.\n// ---------------------------------------------------------------------------\n\nconst exhausted = (n: number, attempt: number): AiError.AiError =>\n new AiError.InvalidRequest({\n provider: \"mock\",\n raw: `MockProvider exhausted: ${n} turns scripted, but call ${attempt} was made`,\n })\n\nconst noRecord = (_: Call): Effect.Effect<void> => Effect.void\n\nconst buildService = (\n scriptedTurns: ReadonlyArray<Turn>,\n options: MockOptions | undefined,\n cursor: Ref.Ref<number>,\n record: (call: Call) => Effect.Effect<void>,\n): LanguageModelService => {\n const streamTurn: LanguageModelService[\"streamTurn\"] = (request) =>\n Stream.unwrap(\n Ref.getAndUpdate(cursor, (n) => n + 1).pipe(\n Effect.flatMap(\n (i): Effect.Effect<Stream.Stream<TurnEvent, AiError.AiError>> =>\n Option.match(Arr.get(scriptedTurns, i), {\n onNone: () => Effect.succeed(Stream.fail(exhausted(scriptedTurns.length, i + 1))),\n onSome: (turn) =>\n record({ history: request.history, turn }).pipe(\n Effect.as(pacedDeltas(turn, options)),\n ),\n }),\n ),\n ),\n )\n return { streamTurn, turn: turnFromStream(streamTurn) }\n}\n\n// ---------------------------------------------------------------------------\n// Recorder handle. Unsafe Ref is local: it backs both the `record` write\n// hook (called inside the service) and the `recorder` read effect (called\n// by the test). Both close over the same cell.\n// ---------------------------------------------------------------------------\n\ntype RecorderHandle = {\n readonly record: (call: Call) => Effect.Effect<void>\n readonly recorder: Effect.Effect<MockRecorder>\n}\n\nconst makeRecorderUnsafe = (): RecorderHandle => {\n const ref = Ref.makeUnsafe<ReadonlyArray<Call>>([])\n return {\n record: (call) => Ref.update(ref, Arr.append(call)),\n recorder: Ref.get(ref).pipe(Effect.map((calls): MockRecorder => ({ calls }))),\n }\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Layer that registers a `MockProvider` against the `LanguageModel` tag.\n * Calls beyond the scripted turn count fail with `InvalidRequest`.\n */\nexport const layer = (\n scriptedTurns: ReadonlyArray<Turn>,\n options?: MockOptions,\n): Layer.Layer<LanguageModel> =>\n Layer.effect(\n LanguageModel,\n Ref.make(0).pipe(\n Effect.map((cursor) => buildService(scriptedTurns, options, cursor, noRecord)),\n ),\n )\n\n/**\n * Like `layer`, but also exposes a recorder that captures every call\n * (history + returned turn).\n */\nexport const layerWithRecorder = (\n scriptedTurns: ReadonlyArray<Turn>,\n options?: MockOptions,\n): {\n readonly layer: Layer.Layer<LanguageModel>\n readonly recorder: Effect.Effect<MockRecorder>\n} => {\n const { record, recorder } = makeRecorderUnsafe()\n return {\n layer: Layer.effect(\n LanguageModel,\n Ref.make(0).pipe(\n Effect.map((cursor) => buildService(scriptedTurns, options, cursor, record)),\n ),\n ),\n recorder,\n }\n}\n\n/**\n * Build the `LanguageModelService` value directly (no Layer), plus a\n * recorder. Use this when you want to swap models mid-program via\n * `Effect.provideService` instead of providing one model for the whole\n * program via `Layer`.\n */\nexport const make = (\n scriptedTurns: ReadonlyArray<Turn>,\n options?: MockOptions,\n): {\n readonly service: LanguageModelService\n readonly recorder: Effect.Effect<MockRecorder>\n} => {\n const cursor = Ref.makeUnsafe(0)\n const { record, recorder } = makeRecorderUnsafe()\n return {\n service: buildService(scriptedTurns, options, cursor, record),\n recorder,\n }\n}\n"],"mappings":";;;;;;AAsCA,MAAM,eAAyD,MAAM,MAAY,CAAC,KAChF,MAAM,eAAe,OAAO,CAAC;CAC3B,UAAU,MACR,EAAE,SAAS,cACP,EAAE,QAAQ,OAAO,aAAa,CAAC,KAAK,MAAM,UAAU,UAAU,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,GAChF,EAAE;CACR,gBAAgB,OAAO,CACrB,UAAU,cAAc;EAAE,SAAS,GAAG;EAAS,MAAM,GAAG;EAAM,CAAC,EAC/D,UAAU,kBAAkB;EAAE,SAAS,GAAG;EAAS,OAAO,GAAG;EAAW,CAAC,CAC1E;CACD,4BAA4B,EAAE;CAC9B,YAAY,MACV,EAAE,YAAY,KAAA,IACV,CAAC,UAAU,eAAe;EAAE,MAAM,EAAE;EAAS,MAAM;EAAoB,CAAC,CAAC,GACzE,EAAE;CACT,CAAC,EACF,MAAM,WACP;AAED,MAAM,gBAAgB,SAAyC,CAC7D,GAAG,KAAK,MAAM,QAAQ,aAAa,EACnC,UAAU,aAAa,EAAE,MAAM,CAAC,CACjC;AAED,MAAM,eAAe,MAAY,YAAoD;CACnF,MAAM,OAAO,OAAO,aAAa,aAAa,KAAK,CAAC;AACpD,QAAO,SAAS,kBAAkB,KAAA,IAC9B,OACA,KAAK,KAAK,OAAO,SAAS,SAAS,OAAO,QAAQ,cAAc,CAAC,CAAC;;AAQxE,MAAM,aAAa,GAAW,YAC5B,IAAIA,eAAuB;CACzB,UAAU;CACV,KAAK,2BAA2B,EAAE,4BAA4B,QAAQ;CACvE,CAAC;AAEJ,MAAM,YAAY,MAAiC,OAAO;AAE1D,MAAM,gBACJ,eACA,SACA,QACA,WACyB;CACzB,MAAM,cAAkD,YACtD,OAAO,OACL,IAAI,aAAa,SAAS,MAAM,IAAI,EAAE,CAAC,KACrC,OAAO,SACJ,MACC,OAAO,MAAMC,MAAI,IAAI,eAAe,EAAE,EAAE;EACtC,cAAc,OAAO,QAAQ,OAAO,KAAK,UAAU,cAAc,QAAQ,IAAI,EAAE,CAAC,CAAC;EACjF,SAAS,SACP,OAAO;GAAE,SAAS,QAAQ;GAAS;GAAM,CAAC,CAAC,KACzC,OAAO,GAAG,YAAY,MAAM,QAAQ,CAAC,CACtC;EACJ,CAAC,CACL,CACF,CACF;AACH,QAAO;EAAE;EAAY,MAAM,eAAe,WAAW;EAAE;;AAczD,MAAM,2BAA2C;CAC/C,MAAM,MAAM,IAAI,WAAgC,EAAE,CAAC;AACnD,QAAO;EACL,SAAS,SAAS,IAAI,OAAO,KAAKA,MAAI,OAAO,KAAK,CAAC;EACnD,UAAU,IAAI,IAAI,IAAI,CAAC,KAAK,OAAO,KAAK,WAAyB,EAAE,OAAO,EAAE,CAAC;EAC9E;;;;;;AAWH,MAAa,SACX,eACA,YAEA,MAAM,OACJ,eACA,IAAI,KAAK,EAAE,CAAC,KACV,OAAO,KAAK,WAAW,aAAa,eAAe,SAAS,QAAQ,SAAS,CAAC,CAC/E,CACF;;;;;AAMH,MAAa,qBACX,eACA,YAIG;CACH,MAAM,EAAE,QAAQ,aAAa,oBAAoB;AACjD,QAAO;EACL,OAAO,MAAM,OACX,eACA,IAAI,KAAK,EAAE,CAAC,KACV,OAAO,KAAK,WAAW,aAAa,eAAe,SAAS,QAAQ,OAAO,CAAC,CAC7E,CACF;EACD;EACD;;;;;;;;AASH,MAAa,QACX,eACA,YAIG;CACH,MAAM,SAAS,IAAI,WAAW,EAAE;CAChC,MAAM,EAAE,QAAQ,aAAa,oBAAoB;AACjD,QAAO;EACL,SAAS,aAAa,eAAe,SAAS,QAAQ,OAAO;EAC7D;EACD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-uai/core",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Low-level primitives (loop, conversation, items, tools, streaming codecs) for building AI agents with Effect.",
5
5
  "keywords": [
6
6
  "agents",
@@ -94,6 +94,10 @@
94
94
  "types": "./dist/loop/Loop.d.mts",
95
95
  "import": "./dist/loop/Loop.mjs"
96
96
  },
97
+ "./Retry": {
98
+ "types": "./dist/retry/Retry.d.mts",
99
+ "import": "./dist/retry/Retry.mjs"
100
+ },
97
101
  "./Vector": {
98
102
  "types": "./dist/math/Vector.d.mts",
99
103
  "import": "./dist/math/Vector.mjs"
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export * as SpeechSynthesizer from "./speech-synthesizer/SpeechSynthesizer.js"
14
14
  export * as Transcriber from "./transcriber/Transcriber.js"
15
15
  export * as Vector from "./math/Vector.js"
16
16
  export * as Loop from "./loop/Loop.js"
17
+ export * as Retry from "./retry/Retry.js"
17
18
  export * as Tool from "./tool/Tool.js"
18
19
  export * as Toolkit from "./tool/Toolkit.js"
19
20
  export * as Outcome from "./tool/Outcome.js"
@@ -1,9 +1,13 @@
1
- import { Cause, Effect, Exit, Layer, Option, Ref, Schedule, Stream } from "effect"
1
+ import { Cause, Effect, Exit, Layer, Option, Stream } from "effect"
2
2
  import { describe, expect, it } from "vitest"
3
3
  import * as AiError from "../domain/AiError.js"
4
4
  import * as Items from "../domain/Items.js"
5
5
  import { type Turn, TurnEvent } from "../domain/Turn.js"
6
- import { LanguageModel, retry, turn } from "./LanguageModel.js"
6
+ import { LanguageModel, type LanguageModelService, turn, turnFromStream } from "./LanguageModel.js"
7
+
8
+ const serviceFromStream = (
9
+ streamTurn: LanguageModelService["streamTurn"],
10
+ ): LanguageModelService => ({ streamTurn, turn: turnFromStream(streamTurn) })
7
11
  import * as MockProvider from "../testing/MockProvider.js"
8
12
 
9
13
  const oneTextTurn = (text: string): Turn => ({
@@ -26,9 +30,12 @@ describe("LanguageModel.turn", () => {
26
30
 
27
31
  it("fails with IncompleteTurn when the stream ends without TurnComplete", async () => {
28
32
  // 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
- })
33
+ const broken = Layer.succeed(
34
+ LanguageModel,
35
+ serviceFromStream(() =>
36
+ Stream.fromIterable<TurnEvent>([TurnEvent.TextDelta({ text: "partial" })]),
37
+ ),
38
+ )
32
39
 
33
40
  const program = turn({ history: [Items.userText("hi")], model: "mock" })
34
41
  const exit = await Effect.runPromise(Effect.exit(program.pipe(Effect.provide(broken))))
@@ -45,9 +52,10 @@ describe("LanguageModel.turn", () => {
45
52
 
46
53
  it("propagates an AiError raised by streamTurn", async () => {
47
54
  const rateLimited = new AiError.RateLimited({ provider: "mock", raw: null })
48
- const failing = Layer.succeed(LanguageModel, {
49
- streamTurn: () => Stream.fail<AiError.AiError>(rateLimited),
50
- })
55
+ const failing = Layer.succeed(
56
+ LanguageModel,
57
+ serviceFromStream(() => Stream.fail<AiError.AiError>(rateLimited)),
58
+ )
51
59
 
52
60
  const program = turn({ history: [], model: "mock" })
53
61
  const exit = await Effect.runPromise(Effect.exit(program.pipe(Effect.provide(failing))))
@@ -63,13 +71,15 @@ describe("LanguageModel.turn", () => {
63
71
  // should pick the last one (the most recent assembled Turn).
64
72
  const first = oneTextTurn("first")
65
73
  const second = oneTextTurn("second")
66
- const weird = Layer.succeed(LanguageModel, {
67
- streamTurn: () =>
74
+ const weird = Layer.succeed(
75
+ LanguageModel,
76
+ serviceFromStream(() =>
68
77
  Stream.fromIterable<TurnEvent>([
69
78
  TurnEvent.TurnComplete({ turn: first }),
70
79
  TurnEvent.TurnComplete({ turn: second }),
71
80
  ]),
72
- })
81
+ ),
82
+ )
73
83
 
74
84
  const program = turn({ history: [], model: "mock" })
75
85
  const result = await Effect.runPromise(program.pipe(Effect.provide(weird)))
@@ -77,94 +87,3 @@ describe("LanguageModel.turn", () => {
77
87
  expect(result).toEqual(second)
78
88
  })
79
89
  })
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 { Array as Arr, Context, Data, Effect, Option, type Schedule, Stream } from "effect"
1
+ import { Array as Arr, Context, Effect, Option, 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"
@@ -38,6 +38,16 @@ export type CommonRequest = {
38
38
 
39
39
  export type LanguageModelService = {
40
40
  readonly streamTurn: (request: CommonRequest) => Stream.Stream<TurnEvent, AiError.AiError>
41
+ /**
42
+ * Drain a single turn and return the assembled `Turn` from the
43
+ * terminal `TurnComplete` event. Fails with `IncompleteTurn` if the
44
+ * stream ends without one.
45
+ *
46
+ * Most providers derive this from `streamTurn` via
47
+ * {@link turnFromStream}; providers with a native non-streaming
48
+ * endpoint may override with a cheaper direct call.
49
+ */
50
+ readonly turn: (request: CommonRequest) => Effect.Effect<Turn, AiError.AiError>
41
51
  }
42
52
 
43
53
  export class LanguageModel extends Context.Service<LanguageModel, LanguageModelService>()(
@@ -53,64 +63,31 @@ export const streamTurn = (
53
63
  Stream.unwrap(Effect.map(LanguageModel.asEffect(), (m) => m.streamTurn(request)))
54
64
 
55
65
  /**
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.
66
+ * Drain a single turn and return the assembled `Turn`. Delegates to the
67
+ * service's `turn` method — providers with a native complete endpoint
68
+ * can override; the rest get the default streamTurn-drain via
69
+ * {@link turnFromStream}.
59
70
  */
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 }
71
+ export const turn = (request: CommonRequest): Effect.Effect<Turn, AiError.AiError, LanguageModel> =>
72
+ Effect.flatMap(LanguageModel.asEffect(), (m) => m.turn(request))
94
73
 
95
74
  /**
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.
75
+ * Build a `turn` implementation from a `streamTurn` implementation.
76
+ * Providers without a native non-streaming endpoint use this to
77
+ * populate the service's `turn` field. Generic over the request type so
78
+ * provider-typed services (with their narrowed request) can reuse it.
100
79
  */
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),
80
+ export const turnFromStream =
81
+ <Req>(streamTurn: (request: Req) => Stream.Stream<TurnEvent, AiError.AiError>) =>
82
+ (request: Req): Effect.Effect<Turn, AiError.AiError> =>
83
+ streamTurn(request).pipe(
84
+ Stream.runCollect,
85
+ Effect.flatMap((events) =>
86
+ Arr.findLast(events, isTurnComplete).pipe(
87
+ Option.match({
88
+ onNone: () => Effect.fail(new AiError.IncompleteTurn({})),
89
+ onSome: (e) => Effect.succeed(e.turn),
90
+ }),
91
+ ),
115
92
  ),
116
93
  )