@effect-uai/core 0.5.0 → 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.
- package/dist/{Items-DqbaJoz7.d.mts → Items-BH8xUkoR.d.mts} +6 -6
- package/dist/{Items-DqbaJoz7.d.mts.map → Items-BH8xUkoR.d.mts.map} +1 -1
- package/dist/{Tool-Y0__Py1H.d.mts → Tool-87ViKCCO.d.mts} +2 -2
- package/dist/{Tool-Y0__Py1H.d.mts.map → Tool-87ViKCCO.d.mts.map} +1 -1
- package/dist/{Turn-ChbL2foc.d.mts → Turn-0CwCAyVe.d.mts} +2 -2
- package/dist/{Turn-ChbL2foc.d.mts.map → Turn-0CwCAyVe.d.mts.map} +1 -1
- package/dist/domain/Items.d.mts +1 -1
- package/dist/domain/Turn.d.mts +1 -1
- package/dist/index.d.mts +5 -4
- package/dist/index.mjs +2 -1
- package/dist/language-model/LanguageModel.d.mts +27 -25
- package/dist/language-model/LanguageModel.d.mts.map +1 -1
- package/dist/language-model/LanguageModel.mjs +16 -25
- package/dist/language-model/LanguageModel.mjs.map +1 -1
- package/dist/language-model/LanguageModel.test.mjs +10 -81
- package/dist/language-model/LanguageModel.test.mjs.map +1 -1
- package/dist/loop/Loop.d.mts +1 -1
- package/dist/retry/Retry.d.mts +64 -0
- package/dist/retry/Retry.d.mts.map +1 -0
- package/dist/retry/Retry.mjs +69 -0
- package/dist/retry/Retry.mjs.map +1 -0
- package/dist/retry/Retry.test.d.mts +1 -0
- package/dist/retry/Retry.test.mjs +142 -0
- package/dist/retry/Retry.test.mjs.map +1 -0
- package/dist/testing/MockProvider.d.mts +2 -2
- package/dist/testing/MockProvider.d.mts.map +1 -1
- package/dist/testing/MockProvider.mjs +14 -8
- package/dist/testing/MockProvider.mjs.map +1 -1
- package/dist/tool/HistoryCheck.d.mts +1 -1
- package/dist/tool/Outcome.d.mts +1 -1
- package/dist/tool/Resolvers.d.mts +1 -1
- package/dist/tool/Tool.d.mts +1 -1
- package/dist/tool/Toolkit.d.mts +2 -2
- package/package.json +5 -1
- package/src/index.ts +1 -0
- package/src/language-model/LanguageModel.test.ts +21 -102
- package/src/language-model/LanguageModel.ts +33 -56
- package/src/retry/Retry.test.ts +163 -0
- package/src/retry/Retry.ts +84 -0
- package/src/testing/MockProvider.ts +10 -5
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Retry.d.mts","names":[],"sources":["../../src/retry/Retry.ts"],"mappings":";;;;;;;;;cACoD,cAAA;;;;;;;;;;;AAAA;;;;;;;;;;;;;;;;;;cA6BvC,SAAA,SAAkB,cAAA;EAAA,SACpB,KAAA,EAAO,WAAA,GAAsB,WAAA,GAAsB,OAAA;AAAA;;cAIjD,WAAA,GACX,CAAA,EAAG,OAAA,KACF,CAAA,IAAK,WAAA,GAAsB,WAAA,GAAsB,OAAA;;;;;;;cAgBvC,MAAA,QACL,QAAA,EAAU,QAAA,CAAS,QAAA,CAAS,GAAA,EAAK,SAAA,aAChC,CAAA,EAAG,MAAA,CAAO,MAAA,CAAO,CAAA,EAAG,OAAA,EAAiB,CAAA,MAAK,MAAA,CAAO,MAAA,CAAO,CAAA,EAAG,OAAA,EAAiB,CAAA;AAzBrF;;;;;;;;;;AAAA,cAkDa,MAAA,QACL,QAAA,EAAU,QAAA,CAAS,QAAA,CAAS,GAAA,EAAK,OAAA,aAChC,GAAA,EAAK,MAAA,CAAO,MAAA,CAAO,CAAA,EAAG,OAAA,EAAiB,CAAA,MAAK,MAAA,CAAO,MAAA,CAAO,CAAA,EAAG,OAAA,EAAiB,CAAA"}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { n as __exportAll } from "../chunk-uyGKjUfl.mjs";
|
|
2
|
+
import { Data, Effect, Stream } from "effect";
|
|
3
|
+
//#region src/retry/Retry.ts
|
|
4
|
+
var Retry_exports = /* @__PURE__ */ __exportAll({
|
|
5
|
+
Retryable: () => Retryable,
|
|
6
|
+
effect: () => effect,
|
|
7
|
+
isRetryable: () => isRetryable,
|
|
8
|
+
stream: () => stream
|
|
9
|
+
});
|
|
10
|
+
/**
|
|
11
|
+
* Subset-aware retry helpers for `AiError`.
|
|
12
|
+
*
|
|
13
|
+
* Effect's own `Effect.retry` / `Stream.retry` apply the schedule to
|
|
14
|
+
* every failure. For AI calls that's the wrong policy — replaying a
|
|
15
|
+
* `ContentFiltered` or `InvalidRequest` won't change the outcome and
|
|
16
|
+
* just wastes quota. These helpers gate the schedule on the
|
|
17
|
+
* {@link Retryable} subset (rate limits, transport hiccups, timeouts);
|
|
18
|
+
* everything else propagates unchanged.
|
|
19
|
+
*
|
|
20
|
+
* Two carriers:
|
|
21
|
+
* - {@link stream} — for `Stream<A, AiError, R>` (e.g. `streamTurn`,
|
|
22
|
+
* `streamSynthesis`, `streamTranscriptionFrom`). Caveat: the entire
|
|
23
|
+
* stream re-runs on retry, so deltas before the failure replay on
|
|
24
|
+
* the next attempt.
|
|
25
|
+
* - {@link effect} — for `Effect<A, AiError, R>` (e.g. `turn`, `embed`,
|
|
26
|
+
* `embedMany`, `synthesize`, `transcribe`). At-most-once.
|
|
27
|
+
*
|
|
28
|
+
* Composes with `Stream.retry` / `Effect.retry` directly (no naming
|
|
29
|
+
* collision since these live under the `Retry` namespace).
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Wrapper around the retryable subset of `AiError`. Internal to the
|
|
33
|
+
* lift/retry/unlift dance — exported so callers can build their own
|
|
34
|
+
* variants if needed (e.g. a custom schedule that observes `cause`).
|
|
35
|
+
*/
|
|
36
|
+
var Retryable = class extends Data.TaggedError("RetryableAi") {};
|
|
37
|
+
/** Type-narrowing predicate for the retryable subset. */
|
|
38
|
+
const isRetryable = (e) => e._tag === "RateLimited" || e._tag === "Unavailable" || e._tag === "Timeout";
|
|
39
|
+
/**
|
|
40
|
+
* Retry a `Stream<A, AiError, R>` on the retryable subset. Other
|
|
41
|
+
* failures bypass the schedule and propagate unchanged. The whole
|
|
42
|
+
* stream replays on each attempt — deltas emitted before the failure
|
|
43
|
+
* are re-emitted on the next try.
|
|
44
|
+
*/
|
|
45
|
+
const stream = (schedule) => (s) => s.pipe(Stream.map((value) => ({
|
|
46
|
+
_tag: "Item",
|
|
47
|
+
value
|
|
48
|
+
})), Stream.catchIf(isRetryable, (cause) => Stream.fail(new Retryable({ cause })), (cause) => Stream.succeed({
|
|
49
|
+
_tag: "Terminal",
|
|
50
|
+
cause
|
|
51
|
+
})), Stream.retry(schedule), Stream.catchTag("RetryableAi", (e) => Stream.fail(e.cause)), Stream.flatMap((item) => item._tag === "Item" ? Stream.succeed(item.value) : Stream.fail(item.cause)));
|
|
52
|
+
/**
|
|
53
|
+
* Retry an `Effect<A, AiError, R>` on the retryable subset. Other
|
|
54
|
+
* failures bypass the schedule and propagate unchanged. At-most-once
|
|
55
|
+
* — the underlying call only re-runs if the previous attempt failed
|
|
56
|
+
* with a retryable tag.
|
|
57
|
+
*
|
|
58
|
+
* Skips the lift/unlift dance the Stream variant needs: `Effect.retry`
|
|
59
|
+
* exposes a `while:` predicate so the error channel stays a plain
|
|
60
|
+
* `AiError` throughout.
|
|
61
|
+
*/
|
|
62
|
+
const effect = (schedule) => (eff) => Effect.retry(eff, {
|
|
63
|
+
schedule,
|
|
64
|
+
while: isRetryable
|
|
65
|
+
});
|
|
66
|
+
//#endregion
|
|
67
|
+
export { Retryable, effect, isRetryable, stream, Retry_exports as t };
|
|
68
|
+
|
|
69
|
+
//# sourceMappingURL=Retry.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Retry.mjs","names":[],"sources":["../../src/retry/Retry.ts"],"sourcesContent":["import { Data, Effect, type Schedule, Stream } from \"effect\"\nimport type * as AiError from \"../domain/AiError.js\"\n\n/**\n * Subset-aware retry helpers for `AiError`.\n *\n * Effect's own `Effect.retry` / `Stream.retry` apply the schedule to\n * every failure. For AI calls that's the wrong policy — replaying a\n * `ContentFiltered` or `InvalidRequest` won't change the outcome and\n * just wastes quota. These helpers gate the schedule on the\n * {@link Retryable} subset (rate limits, transport hiccups, timeouts);\n * everything else propagates unchanged.\n *\n * Two carriers:\n * - {@link stream} — for `Stream<A, AiError, R>` (e.g. `streamTurn`,\n * `streamSynthesis`, `streamTranscriptionFrom`). Caveat: the entire\n * stream re-runs on retry, so deltas before the failure replay on\n * the next attempt.\n * - {@link effect} — for `Effect<A, AiError, R>` (e.g. `turn`, `embed`,\n * `embedMany`, `synthesize`, `transcribe`). At-most-once.\n *\n * Composes with `Stream.retry` / `Effect.retry` directly (no naming\n * collision since these live under the `Retry` namespace).\n */\n\n/**\n * Wrapper around the retryable subset of `AiError`. Internal to the\n * lift/retry/unlift dance — exported so callers can build their own\n * variants if needed (e.g. a custom schedule that observes `cause`).\n */\nexport class Retryable extends Data.TaggedError(\"RetryableAi\")<{\n readonly cause: AiError.RateLimited | AiError.Unavailable | AiError.Timeout\n}> {}\n\n/** Type-narrowing predicate for the retryable subset. */\nexport const isRetryable = (\n e: AiError.AiError,\n): e is AiError.RateLimited | AiError.Unavailable | AiError.Timeout =>\n e._tag === \"RateLimited\" || e._tag === \"Unavailable\" || e._tag === \"Timeout\"\n\n// Lift events to Items, non-retryable failures to Terminal values\n// (escape retry), retryable failures to wrapped errors (only thing\n// retry sees).\ntype Lifted<A> =\n | { readonly _tag: \"Item\"; readonly value: A }\n | { readonly _tag: \"Terminal\"; readonly cause: AiError.AiError }\n\n/**\n * Retry a `Stream<A, AiError, R>` on the retryable subset. Other\n * failures bypass the schedule and propagate unchanged. The whole\n * stream replays on each attempt — deltas emitted before the failure\n * are re-emitted on the next try.\n */\nexport const stream =\n <Out>(schedule: Schedule.Schedule<Out, Retryable>) =>\n <A, R>(s: Stream.Stream<A, AiError.AiError, R>): Stream.Stream<A, AiError.AiError, R> =>\n s.pipe(\n Stream.map((value): Lifted<A> => ({ _tag: \"Item\", value })),\n Stream.catchIf(\n isRetryable,\n (cause) => Stream.fail(new Retryable({ cause })),\n (cause) => Stream.succeed<Lifted<A>>({ _tag: \"Terminal\", cause }),\n ),\n Stream.retry(schedule),\n Stream.catchTag(\"RetryableAi\", (e) => Stream.fail<AiError.AiError>(e.cause)),\n Stream.flatMap((item) =>\n item._tag === \"Item\" ? Stream.succeed(item.value) : Stream.fail(item.cause),\n ),\n )\n\n/**\n * Retry an `Effect<A, AiError, R>` on the retryable subset. Other\n * failures bypass the schedule and propagate unchanged. At-most-once\n * — the underlying call only re-runs if the previous attempt failed\n * with a retryable tag.\n *\n * Skips the lift/unlift dance the Stream variant needs: `Effect.retry`\n * exposes a `while:` predicate so the error channel stays a plain\n * `AiError` throughout.\n */\nexport const effect =\n <Out>(schedule: Schedule.Schedule<Out, AiError.AiError>) =>\n <A, R>(eff: Effect.Effect<A, AiError.AiError, R>): Effect.Effect<A, AiError.AiError, R> =>\n Effect.retry(eff, { schedule, while: isRetryable })\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,IAAa,YAAb,cAA+B,KAAK,YAAY,cAAc,CAE3D;;AAGH,MAAa,eACX,MAEA,EAAE,SAAS,iBAAiB,EAAE,SAAS,iBAAiB,EAAE,SAAS;;;;;;;AAerE,MAAa,UACL,cACC,MACL,EAAE,KACA,OAAO,KAAK,WAAsB;CAAE,MAAM;CAAQ;CAAO,EAAE,EAC3D,OAAO,QACL,cACC,UAAU,OAAO,KAAK,IAAI,UAAU,EAAE,OAAO,CAAC,CAAC,GAC/C,UAAU,OAAO,QAAmB;CAAE,MAAM;CAAY;CAAO,CAAC,CAClE,EACD,OAAO,MAAM,SAAS,EACtB,OAAO,SAAS,gBAAgB,MAAM,OAAO,KAAsB,EAAE,MAAM,CAAC,EAC5E,OAAO,SAAS,SACd,KAAK,SAAS,SAAS,OAAO,QAAQ,KAAK,MAAM,GAAG,OAAO,KAAK,KAAK,MAAM,CAC5E,CACF;;;;;;;;;;;AAYL,MAAa,UACL,cACC,QACL,OAAO,MAAM,KAAK;CAAE;CAAU,OAAO;CAAa,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -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,5 +1,5 @@
|
|
|
1
|
-
import { d as Item } from "../Items-
|
|
2
|
-
import { r as Turn } from "../Turn-
|
|
1
|
+
import { d as Item } from "../Items-BH8xUkoR.mjs";
|
|
2
|
+
import { r as Turn } from "../Turn-0CwCAyVe.mjs";
|
|
3
3
|
import { LanguageModel, LanguageModelService } from "../language-model/LanguageModel.mjs";
|
|
4
4
|
import { Duration, Effect, Layer } from "effect";
|
|
5
5
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MockProvider.d.mts","names":[],"sources":["../../src/testing/MockProvider.ts"],"mappings":";;;;;;
|
|
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) =>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 {
|
|
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/dist/tool/Outcome.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { o as FunctionCall } from "../Items-
|
|
1
|
+
import { o as FunctionCall } from "../Items-BH8xUkoR.mjs";
|
|
2
2
|
import { ToolResult } from "./Outcome.mjs";
|
|
3
3
|
import { ToolEvent } from "./ToolEvent.mjs";
|
|
4
4
|
import { Data, Effect, Queue, Scope, Stream } from "effect";
|
package/dist/tool/Tool.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as Tool, c as ToolInputSchema, d as fromEffectSchema, f as fromStandardSchema, g as toDescriptors, h as streaming, i as StreamingTool, m as make, n as AnyPlainTool, o as ToolDescriptor, p as isStreamingTool, r as AnyStreamingTool, s as ToolError, t as AnyKindTool, u as execute } from "../Tool-
|
|
1
|
+
import { a as Tool, c as ToolInputSchema, d as fromEffectSchema, f as fromStandardSchema, g as toDescriptors, h as streaming, i as StreamingTool, m as make, n as AnyPlainTool, o as ToolDescriptor, p as isStreamingTool, r as AnyStreamingTool, s as ToolError, t as AnyKindTool, u as execute } from "../Tool-87ViKCCO.mjs";
|
|
2
2
|
export { AnyKindTool, AnyPlainTool, AnyStreamingTool, StreamingTool, Tool, ToolDescriptor, ToolError, ToolInputSchema, execute, fromEffectSchema, fromStandardSchema, isStreamingTool, make, streaming, toDescriptors };
|
package/dist/tool/Toolkit.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { o as FunctionCall } from "../Items-
|
|
2
|
-
import { a as Tool, i as StreamingTool, o as ToolDescriptor, t as AnyKindTool } from "../Tool-
|
|
1
|
+
import { o as FunctionCall } from "../Items-BH8xUkoR.mjs";
|
|
2
|
+
import { a as Tool, i as StreamingTool, o as ToolDescriptor, t as AnyKindTool } from "../Tool-87ViKCCO.mjs";
|
|
3
3
|
import { Event } from "../loop/Loop.mjs";
|
|
4
4
|
import { ToolResult } from "./Outcome.mjs";
|
|
5
5
|
import { ToolEvent } from "./ToolEvent.mjs";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-uai/core",
|
|
3
|
-
"version": "0.5.
|
|
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,
|
|
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,
|
|
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(
|
|
30
|
-
|
|
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(
|
|
49
|
-
|
|
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(
|
|
67
|
-
|
|
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
|
-
})
|