@effect-uai/core 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/{Items-BH8xUkoR.d.mts → Items-pXR5BAgW.d.mts} +12 -12
- package/dist/Items-pXR5BAgW.d.mts.map +1 -0
- package/dist/SandboxError-BvKypkJm.d.mts +158 -0
- package/dist/SandboxError-BvKypkJm.d.mts.map +1 -0
- package/dist/{Tool-87ViKCCO.d.mts → Tool-DP-AwKOf.d.mts} +11 -11
- package/dist/{Tool-87ViKCCO.d.mts.map → Tool-DP-AwKOf.d.mts.map} +1 -1
- package/dist/domain/Items.d.mts +2 -2
- package/dist/domain/Items.mjs +15 -15
- package/dist/domain/Items.mjs.map +1 -1
- package/dist/domain/Turn.d.mts +388 -2
- package/dist/domain/Turn.d.mts.map +1 -0
- package/dist/domain/Turn.mjs +13 -13
- package/dist/domain/Turn.mjs.map +1 -1
- package/dist/index.d.mts +12 -6
- package/dist/index.mjs +9 -3
- package/dist/language-model/LanguageModel.d.mts +29 -27
- 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 +37 -90
- package/dist/loop/Loop.d.mts.map +1 -1
- package/dist/loop/Loop.mjs +39 -93
- package/dist/loop/Loop.mjs.map +1 -1
- package/dist/loop/Loop.test.mjs +46 -54
- package/dist/loop/Loop.test.mjs.map +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.mjs +142 -0
- package/dist/retry/Retry.test.mjs.map +1 -0
- package/dist/sandbox/Memory.d.mts +53 -0
- package/dist/sandbox/Memory.d.mts.map +1 -0
- package/dist/sandbox/Memory.mjs +68 -0
- package/dist/sandbox/Memory.mjs.map +1 -0
- package/dist/sandbox/Sandbox.d.mts +717 -0
- package/dist/sandbox/Sandbox.d.mts.map +1 -0
- package/dist/sandbox/Sandbox.mjs +192 -0
- package/dist/sandbox/Sandbox.mjs.map +1 -0
- package/dist/sandbox/SandboxError.d.mts +2 -0
- package/dist/sandbox/SandboxError.mjs +89 -0
- package/dist/sandbox/SandboxError.mjs.map +1 -0
- package/dist/sandbox/SandboxImage.d.mts +28 -0
- package/dist/sandbox/SandboxImage.d.mts.map +1 -0
- package/dist/sandbox/SandboxImage.mjs +32 -0
- package/dist/sandbox/SandboxImage.mjs.map +1 -0
- package/dist/sandbox/SandboxNetwork.d.mts +34 -0
- package/dist/sandbox/SandboxNetwork.d.mts.map +1 -0
- package/dist/sandbox/SandboxNetwork.mjs +36 -0
- package/dist/sandbox/SandboxNetwork.mjs.map +1 -0
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +98 -2
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts.map +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.mjs +32 -2
- package/dist/speech-synthesizer/SpeechSynthesizer.mjs.map +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +56 -2
- package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs.map +1 -1
- package/dist/testing/MockProvider.d.mts +3 -3
- 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/testing/MockSandbox.d.mts +604 -0
- package/dist/testing/MockSandbox.d.mts.map +1 -0
- package/dist/testing/MockSandbox.mjs +160 -0
- package/dist/testing/MockSandbox.mjs.map +1 -0
- package/dist/testing/MockSpeechSynthesizer.d.mts +26 -11
- package/dist/testing/MockSpeechSynthesizer.d.mts.map +1 -1
- package/dist/testing/MockSpeechSynthesizer.mjs +75 -51
- package/dist/testing/MockSpeechSynthesizer.mjs.map +1 -1
- package/dist/tool/{Resolvers.d.mts → Approval.d.mts} +27 -27
- package/dist/tool/Approval.d.mts.map +1 -0
- package/dist/tool/{Resolvers.mjs → Approval.mjs} +18 -18
- package/dist/tool/Approval.mjs.map +1 -0
- package/dist/tool/Approval.test.d.mts +1 -0
- package/dist/tool/{Resolvers.test.mjs → Approval.test.mjs} +40 -40
- package/dist/tool/Approval.test.mjs.map +1 -0
- package/dist/tool/HistoryCheck.d.mts +6 -6
- package/dist/tool/HistoryCheck.d.mts.map +1 -1
- package/dist/tool/HistoryCheck.mjs +5 -5
- package/dist/tool/HistoryCheck.mjs.map +1 -1
- package/dist/tool/Tool.d.mts +2 -2
- package/dist/tool/Tool.mjs +4 -4
- package/dist/tool/Tool.mjs.map +1 -1
- package/dist/tool/ToolEvent.d.mts +19 -19
- package/dist/tool/ToolEvent.d.mts.map +1 -1
- package/dist/tool/ToolEvent.mjs +8 -8
- package/dist/tool/ToolEvent.mjs.map +1 -1
- package/dist/tool/{Outcome.d.mts → ToolResult.d.mts} +29 -29
- package/dist/tool/ToolResult.d.mts.map +1 -0
- package/dist/tool/ToolResult.mjs +61 -0
- package/dist/tool/ToolResult.mjs.map +1 -0
- package/dist/tool/Toolkit.d.mts +58 -26
- package/dist/tool/Toolkit.d.mts.map +1 -1
- package/dist/tool/Toolkit.mjs +62 -34
- package/dist/tool/Toolkit.mjs.map +1 -1
- package/dist/tool/Toolkit.test.mjs +13 -13
- package/dist/tool/Toolkit.test.mjs.map +1 -1
- package/package.json +35 -7
- package/src/domain/Items.ts +9 -9
- package/src/domain/Turn.ts +16 -16
- package/src/index.ts +8 -2
- package/src/language-model/LanguageModel.test.ts +21 -102
- package/src/language-model/LanguageModel.ts +36 -59
- package/src/loop/Loop.test.ts +93 -91
- package/src/loop/Loop.ts +72 -170
- package/src/retry/Retry.test.ts +163 -0
- package/src/retry/Retry.ts +84 -0
- package/src/sandbox/Memory.ts +128 -0
- package/src/sandbox/Sandbox.ts +639 -0
- package/src/sandbox/SandboxError.ts +140 -0
- package/src/sandbox/SandboxImage.ts +26 -0
- package/src/sandbox/SandboxNetwork.ts +34 -0
- package/src/speech-synthesizer/SpeechSynthesizer.test.ts +89 -0
- package/src/speech-synthesizer/SpeechSynthesizer.ts +124 -0
- package/src/testing/MockProvider.ts +31 -25
- package/src/testing/MockSandbox.ts +342 -0
- package/src/testing/MockSpeechSynthesizer.ts +137 -77
- package/src/tool/{Resolvers.test.ts → Approval.test.ts} +45 -54
- package/src/tool/{Resolvers.ts → Approval.ts} +25 -25
- package/src/tool/HistoryCheck.ts +10 -13
- package/src/tool/Tool.ts +13 -15
- package/src/tool/ToolEvent.ts +7 -7
- package/src/tool/{Outcome.ts → ToolResult.ts} +20 -20
- package/src/tool/Toolkit.test.ts +13 -13
- package/src/tool/Toolkit.ts +97 -73
- package/dist/Items-BH8xUkoR.d.mts.map +0 -1
- package/dist/Turn-0CwCAyVe.d.mts +0 -388
- package/dist/Turn-0CwCAyVe.d.mts.map +0 -1
- package/dist/tool/Outcome.d.mts.map +0 -1
- package/dist/tool/Outcome.mjs +0 -61
- package/dist/tool/Outcome.mjs.map +0 -1
- package/dist/tool/Resolvers.d.mts.map +0 -1
- package/dist/tool/Resolvers.mjs.map +0 -1
- package/dist/tool/Resolvers.test.mjs.map +0 -1
- /package/dist/{tool/Resolvers.test.d.mts → retry/Retry.test.d.mts} +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { Array as Arr, Context,
|
|
1
|
+
import { Array as Arr, Context, Effect, Option, Stream } from "effect"
|
|
2
2
|
import * as AiError from "../domain/AiError.js"
|
|
3
|
-
import type {
|
|
3
|
+
import type { HistoryItem } from "../domain/Items.js"
|
|
4
4
|
import type * as StructuredFormat from "../structured-format/StructuredFormat.js"
|
|
5
5
|
import type { ToolDescriptor } from "../tool/Tool.js"
|
|
6
6
|
import { isTurnComplete, type Turn, type TurnEvent } from "../domain/Turn.js"
|
|
@@ -12,7 +12,7 @@ import { isTurnComplete, type Turn, type TurnEvent } from "../domain/Turn.js"
|
|
|
12
12
|
* ...) lives in that provider's own request interface, which extends this.
|
|
13
13
|
*/
|
|
14
14
|
export type CommonRequest = {
|
|
15
|
-
readonly history: ReadonlyArray<
|
|
15
|
+
readonly history: ReadonlyArray<HistoryItem>
|
|
16
16
|
/**
|
|
17
17
|
* Model identifier. Each provider narrows this to its typed literal union,
|
|
18
18
|
* so code that yields a typed provider tag gets autocompletion.
|
|
@@ -29,7 +29,7 @@ export type CommonRequest = {
|
|
|
29
29
|
readonly maxOutputTokens?: number
|
|
30
30
|
/**
|
|
31
31
|
* Schema-bound JSON output. The provider constrains the wire to match the
|
|
32
|
-
* schema; pair with `Turn.
|
|
32
|
+
* schema; pair with `Turn.decodeStructured` for runtime validation. Supported
|
|
33
33
|
* across all current providers (OpenAI Responses json_schema, Anthropic
|
|
34
34
|
* `output_config`, Gemini `responseJsonSchema`).
|
|
35
35
|
*/
|
|
@@ -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
|
|
57
|
-
* `
|
|
58
|
-
*
|
|
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
|
|
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
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
|
102
|
-
<
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
Stream.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
)
|
package/src/loop/Loop.test.ts
CHANGED
|
@@ -3,17 +3,13 @@ import { describe, expect, expectTypeOf, it } from "vitest"
|
|
|
3
3
|
import * as AiError from "../domain/AiError.js"
|
|
4
4
|
import { TurnEvent } from "../domain/Turn.js"
|
|
5
5
|
import {
|
|
6
|
-
type
|
|
6
|
+
type Step,
|
|
7
7
|
loop,
|
|
8
|
-
|
|
8
|
+
loopOver,
|
|
9
9
|
loopWithState,
|
|
10
10
|
next,
|
|
11
|
-
nextAfter,
|
|
12
11
|
onTurnComplete,
|
|
13
12
|
stop,
|
|
14
|
-
stopAfter,
|
|
15
|
-
stopEvent,
|
|
16
|
-
stopWith,
|
|
17
13
|
value,
|
|
18
14
|
} from "./Loop.js"
|
|
19
15
|
|
|
@@ -22,8 +18,8 @@ describe("Loop.loop", () => {
|
|
|
22
18
|
// Each iter emits [n, n + 0.5] then continues; final iter emits [n] and stops.
|
|
23
19
|
const stream = loop(0, (n: number) =>
|
|
24
20
|
n >= 3
|
|
25
|
-
?
|
|
26
|
-
:
|
|
21
|
+
? Stream.fromIterable([n]).pipe(Stream.map(value), Stream.concat(stop()))
|
|
22
|
+
: Stream.fromIterable([n, n + 0.5]).pipe(Stream.map(value), Stream.concat(next(n + 1))),
|
|
27
23
|
)
|
|
28
24
|
|
|
29
25
|
const result = await Effect.runPromise(Stream.runCollect(stream))
|
|
@@ -32,9 +28,7 @@ describe("Loop.loop", () => {
|
|
|
32
28
|
|
|
33
29
|
it("supports iterations that emit zero values and only decide", async () => {
|
|
34
30
|
// Every iteration emits nothing, just bumps state; stops at 5.
|
|
35
|
-
const stream = loop(0, (n: number) =>
|
|
36
|
-
n >= 5 ? Stream.fromIterable([stopEvent]) : Stream.fromIterable([next(n + 1)]),
|
|
37
|
-
)
|
|
31
|
+
const stream = loop(0, (n: number) => (n >= 5 ? stop() : next(n + 1)))
|
|
38
32
|
|
|
39
33
|
const result = await Effect.runPromise(Stream.runCollect(stream))
|
|
40
34
|
expect(result).toEqual([])
|
|
@@ -47,8 +41,8 @@ describe("Loop.loop", () => {
|
|
|
47
41
|
Effect.gen(function* () {
|
|
48
42
|
const doubled = yield* Effect.succeed(n * 2)
|
|
49
43
|
return doubled >= 16
|
|
50
|
-
?
|
|
51
|
-
:
|
|
44
|
+
? Stream.fromIterable([doubled]).pipe(Stream.map(value), Stream.concat(stop()))
|
|
45
|
+
: Stream.fromIterable([doubled]).pipe(Stream.map(value), Stream.concat(next(doubled)))
|
|
52
46
|
}),
|
|
53
47
|
)
|
|
54
48
|
|
|
@@ -62,8 +56,8 @@ describe("Loop.loop", () => {
|
|
|
62
56
|
Effect.gen(function* () {
|
|
63
57
|
const doubled = yield* Effect.succeed(n * 2)
|
|
64
58
|
return doubled >= 4
|
|
65
|
-
?
|
|
66
|
-
:
|
|
59
|
+
? Stream.fromIterable([doubled]).pipe(Stream.map(value), Stream.concat(stop()))
|
|
60
|
+
: Stream.fromIterable([doubled]).pipe(Stream.map(value), Stream.concat(next(doubled)))
|
|
67
61
|
}),
|
|
68
62
|
),
|
|
69
63
|
)
|
|
@@ -76,8 +70,8 @@ describe("Loop.loop", () => {
|
|
|
76
70
|
const boom = new Error("boom")
|
|
77
71
|
const stream = loop(
|
|
78
72
|
0,
|
|
79
|
-
(n: number): Stream.Stream<
|
|
80
|
-
n === 2 ? Stream.fail(boom) : Stream.
|
|
73
|
+
(n: number): Stream.Stream<Step<number, number>, Error> =>
|
|
74
|
+
n === 2 ? Stream.fail(boom) : Stream.succeed(value(n)).pipe(Stream.concat(next(n + 1))),
|
|
81
75
|
)
|
|
82
76
|
|
|
83
77
|
const result = await Effect.runPromiseExit(Stream.runCollect(stream))
|
|
@@ -93,14 +87,17 @@ describe("Loop.loop", () => {
|
|
|
93
87
|
})
|
|
94
88
|
|
|
95
89
|
it("short-circuits the body's stream when a Decision is seen", async () => {
|
|
96
|
-
// Body emits
|
|
97
|
-
// body
|
|
98
|
-
// flows to the outer stream. This is the correct behavior: a
|
|
99
|
-
// marks "I'm done with this iteration"; anything after it is dead
|
|
90
|
+
// Body emits value(n), then a Next decision, then value(n+10). Once the
|
|
91
|
+
// decision is encountered, the body is closed - `n+10` is never pulled, so
|
|
92
|
+
// it never flows to the outer stream. This is the correct behavior: a
|
|
93
|
+
// decision marks "I'm done with this iteration"; anything after it is dead.
|
|
100
94
|
const stream = loop(0, (n: number) =>
|
|
101
95
|
n >= 2
|
|
102
|
-
? Stream.
|
|
103
|
-
: Stream.
|
|
96
|
+
? Stream.succeed(value(n)).pipe(Stream.concat(stop()))
|
|
97
|
+
: Stream.succeed(value(n)).pipe(
|
|
98
|
+
Stream.concat(next(n + 1)),
|
|
99
|
+
Stream.concat(Stream.succeed(value(n + 10))),
|
|
100
|
+
),
|
|
104
101
|
)
|
|
105
102
|
|
|
106
103
|
const result = await Effect.runPromise(Stream.runCollect(stream))
|
|
@@ -130,7 +127,7 @@ describe("Loop.loop", () => {
|
|
|
130
127
|
|
|
131
128
|
it("type: onTurnComplete inside loop infers S and A from the handler without annotation", () => {
|
|
132
129
|
// Regression: when piped through loop, onTurnComplete's handler return
|
|
133
|
-
// type (Stream<
|
|
130
|
+
// type (Stream<Step<A, S>>) is the single source of truth for the loop
|
|
134
131
|
// body's element type. The previous workaround required explicit
|
|
135
132
|
// <S, A> at the call site. Now the loop's outer-return generics pull
|
|
136
133
|
// them through automatically.
|
|
@@ -146,10 +143,11 @@ describe("Loop.loop", () => {
|
|
|
146
143
|
onTurnComplete(() =>
|
|
147
144
|
Effect.sync(() =>
|
|
148
145
|
state.turns >= 1
|
|
149
|
-
? stop
|
|
150
|
-
:
|
|
151
|
-
|
|
152
|
-
|
|
146
|
+
? stop()
|
|
147
|
+
: Stream.succeed<ToolEvent>({ _tag: "tool", name: "x" }).pipe(
|
|
148
|
+
Stream.map(value),
|
|
149
|
+
Stream.concat(next({ turns: state.turns + 1 })),
|
|
150
|
+
),
|
|
153
151
|
),
|
|
154
152
|
),
|
|
155
153
|
)
|
|
@@ -170,8 +168,8 @@ describe("Loop.loop", () => {
|
|
|
170
168
|
const textDelta: TurnEvent = TurnEvent.TextDelta({ text: "hi" })
|
|
171
169
|
const deltas: Stream.Stream<TurnEvent> = Stream.fromIterable([textDelta, turnComplete])
|
|
172
170
|
|
|
173
|
-
const dataFirst = onTurnComplete(deltas, () => Effect.sync(() => stop))
|
|
174
|
-
const dataLast = deltas.pipe(onTurnComplete(() => Effect.sync(() => stop)))
|
|
171
|
+
const dataFirst = onTurnComplete(deltas, () => Effect.sync(() => stop()))
|
|
172
|
+
const dataLast = deltas.pipe(onTurnComplete(() => Effect.sync(() => stop())))
|
|
175
173
|
|
|
176
174
|
const a = await Effect.runPromise(Stream.runCollect(dataFirst))
|
|
177
175
|
const b = await Effect.runPromise(Stream.runCollect(dataLast))
|
|
@@ -186,8 +184,8 @@ describe("Loop.loop", () => {
|
|
|
186
184
|
const N = 100_000
|
|
187
185
|
const stream = loop(0, (n: number) =>
|
|
188
186
|
n >= N
|
|
189
|
-
? Stream.
|
|
190
|
-
: Stream.
|
|
187
|
+
? Stream.succeed(value(n)).pipe(Stream.concat(stop()))
|
|
188
|
+
: Stream.succeed(value(n)).pipe(Stream.concat(next(n + 1))),
|
|
191
189
|
)
|
|
192
190
|
|
|
193
191
|
const count = await Effect.runPromise(
|
|
@@ -254,7 +252,7 @@ const scriptedModel = (script: ReadonlyArray<ReadonlyArray<Delta>>): MockModel =
|
|
|
254
252
|
* 2. flatMap projects deltas into UiEvents forwarded to the outer stream.
|
|
255
253
|
* 3. Continuation reads the captured calls; if any, runs them, emits
|
|
256
254
|
* tool_result events, builds the next state (with model swap if a tool
|
|
257
|
-
* asked for one), and emits `next(state)`. Otherwise `stop`.
|
|
255
|
+
* asked for one), and emits `next(state)`. Otherwise `stop()`.
|
|
258
256
|
*/
|
|
259
257
|
const conversationLoop = (initial: State, runTool: ToolRunner) =>
|
|
260
258
|
loop(initial, (state) =>
|
|
@@ -265,7 +263,7 @@ const conversationLoop = (initial: State, runTool: ToolRunner) =>
|
|
|
265
263
|
ReadonlyArray<{ readonly id: string; readonly name: string }>
|
|
266
264
|
>([])
|
|
267
265
|
|
|
268
|
-
const deltas: Stream.Stream<
|
|
266
|
+
const deltas: Stream.Stream<Step<UiEvent, State>> = state.model
|
|
269
267
|
.streamTurn(state.history)
|
|
270
268
|
.pipe(
|
|
271
269
|
Stream.tap((d) =>
|
|
@@ -274,7 +272,7 @@ const conversationLoop = (initial: State, runTool: ToolRunner) =>
|
|
|
274
272
|
: Ref.update(toolCallsRef, (t) => [...t, { id: d.id, name: d.name }]),
|
|
275
273
|
),
|
|
276
274
|
Stream.flatMap(
|
|
277
|
-
(d): Stream.Stream<
|
|
275
|
+
(d): Stream.Stream<Step<UiEvent, State>> =>
|
|
278
276
|
d.type === "text"
|
|
279
277
|
? Stream.fromIterable([value<UiEvent>({ type: "text", text: d.text })])
|
|
280
278
|
: Stream.fromIterable([
|
|
@@ -283,13 +281,13 @@ const conversationLoop = (initial: State, runTool: ToolRunner) =>
|
|
|
283
281
|
),
|
|
284
282
|
)
|
|
285
283
|
|
|
286
|
-
const continuation: Stream.Stream<
|
|
284
|
+
const continuation: Stream.Stream<Step<UiEvent, State>> = Stream.unwrap(
|
|
287
285
|
Effect.gen(function* () {
|
|
288
286
|
const texts = yield* Ref.get(textsRef)
|
|
289
287
|
const toolCalls = yield* Ref.get(toolCallsRef)
|
|
290
288
|
|
|
291
289
|
if (toolCalls.length === 0) {
|
|
292
|
-
return
|
|
290
|
+
return stop()
|
|
293
291
|
}
|
|
294
292
|
|
|
295
293
|
const turnItems: ReadonlyArray<HistoryItem> = [
|
|
@@ -326,7 +324,10 @@ const conversationLoop = (initial: State, runTool: ToolRunner) =>
|
|
|
326
324
|
model: nextModel,
|
|
327
325
|
}
|
|
328
326
|
|
|
329
|
-
return
|
|
327
|
+
return Stream.fromIterable(events).pipe(
|
|
328
|
+
Stream.map(value),
|
|
329
|
+
Stream.concat(next(nextState)),
|
|
330
|
+
)
|
|
330
331
|
}),
|
|
331
332
|
)
|
|
332
333
|
|
|
@@ -419,8 +420,8 @@ describe("Loop.loop - pull-specific stream semantics", () => {
|
|
|
419
420
|
Ref.update(callsRef, (calls) => calls + 1).pipe(
|
|
420
421
|
Effect.as(
|
|
421
422
|
n >= 10
|
|
422
|
-
? Stream.
|
|
423
|
-
: Stream.
|
|
423
|
+
? Stream.succeed(value(n)).pipe(Stream.concat(stop()))
|
|
424
|
+
: Stream.succeed(value(n)).pipe(Stream.concat(next(n + 1))),
|
|
424
425
|
),
|
|
425
426
|
),
|
|
426
427
|
),
|
|
@@ -449,8 +450,11 @@ describe("Loop.loop - pull-specific stream semantics", () => {
|
|
|
449
450
|
const releasesRef = yield* Ref.make<ReadonlyArray<number>>([])
|
|
450
451
|
const stream = loop(0, (n: number) =>
|
|
451
452
|
(n >= 1
|
|
452
|
-
? Stream.
|
|
453
|
-
: Stream.
|
|
453
|
+
? Stream.succeed(value(n)).pipe(Stream.concat(stop()))
|
|
454
|
+
: Stream.succeed(value(n)).pipe(
|
|
455
|
+
Stream.concat(next(n + 1)),
|
|
456
|
+
Stream.concat(Stream.succeed(value(n + 10))),
|
|
457
|
+
)
|
|
454
458
|
).pipe(Stream.ensuring(Ref.update(releasesRef, (values) => [...values, n]))),
|
|
455
459
|
)
|
|
456
460
|
|
|
@@ -468,7 +472,7 @@ describe("Loop.loop - pull-specific stream semantics", () => {
|
|
|
468
472
|
Effect.gen(function* () {
|
|
469
473
|
const started = yield* Deferred.make<void>()
|
|
470
474
|
const releasesRef = yield* Ref.make(0)
|
|
471
|
-
const body = (): Stream.Stream<
|
|
475
|
+
const body = (): Stream.Stream<Step<number, never>> =>
|
|
472
476
|
Stream.concat(
|
|
473
477
|
Stream.fromEffect(Deferred.succeed(started, undefined).pipe(Effect.as(value(0)))),
|
|
474
478
|
Stream.never,
|
|
@@ -490,7 +494,7 @@ describe("Loop.loop - pull-specific stream semantics", () => {
|
|
|
490
494
|
const defect = new Error("body construction failed")
|
|
491
495
|
const result = await Effect.runPromiseExit(
|
|
492
496
|
Stream.runCollect(
|
|
493
|
-
loop(0, (): Stream.Stream<
|
|
497
|
+
loop(0, (): Stream.Stream<Step<number, never>> => {
|
|
494
498
|
throw defect
|
|
495
499
|
}),
|
|
496
500
|
),
|
|
@@ -504,7 +508,9 @@ describe("Loop.loopWithState", () => {
|
|
|
504
508
|
it("exposes the final state in the SubscriptionRef after the stream completes", async () => {
|
|
505
509
|
const program = Effect.gen(function* () {
|
|
506
510
|
const { stream, state } = yield* loopWithState(0, (n: number) =>
|
|
507
|
-
n >= 3
|
|
511
|
+
n >= 3
|
|
512
|
+
? Stream.fromIterable([n]).pipe(Stream.map(value), Stream.concat(stop()))
|
|
513
|
+
: Stream.fromIterable([n]).pipe(Stream.map(value), Stream.concat(next(n + 1))),
|
|
508
514
|
)
|
|
509
515
|
const values = yield* Stream.runCollect(stream)
|
|
510
516
|
const finalState = yield* SubscriptionRef.get(state)
|
|
@@ -519,9 +525,7 @@ describe("Loop.loopWithState", () => {
|
|
|
519
525
|
|
|
520
526
|
it("the state ref starts at `initial` and stays there if the loop stops without advancing", async () => {
|
|
521
527
|
const program = Effect.gen(function* () {
|
|
522
|
-
const { stream, state } = yield* loopWithState({ count: 7 }, () =>
|
|
523
|
-
Stream.fromIterable([stopEvent]),
|
|
524
|
-
)
|
|
528
|
+
const { stream, state } = yield* loopWithState({ count: 7 }, () => stop())
|
|
525
529
|
yield* Stream.runDrain(stream)
|
|
526
530
|
return yield* SubscriptionRef.get(state)
|
|
527
531
|
})
|
|
@@ -535,7 +539,9 @@ describe("Loop.loopWithState", () => {
|
|
|
535
539
|
// tracks loop state without the body needing to surface it.
|
|
536
540
|
const program = Effect.gen(function* () {
|
|
537
541
|
const { stream, state } = yield* loopWithState(0, (n: number) =>
|
|
538
|
-
n >= 3
|
|
542
|
+
n >= 3
|
|
543
|
+
? Stream.fromIterable([n]).pipe(Stream.map(value), Stream.concat(stop()))
|
|
544
|
+
: Stream.fromIterable([n]).pipe(Stream.map(value), Stream.concat(next(n + 1))),
|
|
539
545
|
)
|
|
540
546
|
const seen: Array<{ value: number; stateAfter: number }> = []
|
|
541
547
|
yield* Stream.runForEach(stream, (v) =>
|
|
@@ -565,7 +571,7 @@ describe("Loop.loopWithState", () => {
|
|
|
565
571
|
const { stream, state } = yield* loopWithState(0, (n: number) =>
|
|
566
572
|
Effect.gen(function* () {
|
|
567
573
|
if (n === 0) yield* Latch.await(start)
|
|
568
|
-
return n >= 3 ?
|
|
574
|
+
return n >= 3 ? stop() : next(n + 1)
|
|
569
575
|
}),
|
|
570
576
|
)
|
|
571
577
|
|
|
@@ -593,8 +599,8 @@ describe("Loop.loopWithState", () => {
|
|
|
593
599
|
const program = Effect.gen(function* () {
|
|
594
600
|
const { stream } = yield* loopWithState(0, (n: number) =>
|
|
595
601
|
n >= 3
|
|
596
|
-
?
|
|
597
|
-
:
|
|
602
|
+
? Stream.fromIterable([n]).pipe(Stream.map(value), Stream.concat(stop()))
|
|
603
|
+
: Stream.fromIterable([n, n + 0.5]).pipe(Stream.map(value), Stream.concat(next(n + 1))),
|
|
598
604
|
)
|
|
599
605
|
return Array.from(yield* Stream.runCollect(stream))
|
|
600
606
|
})
|
|
@@ -603,16 +609,16 @@ describe("Loop.loopWithState", () => {
|
|
|
603
609
|
})
|
|
604
610
|
})
|
|
605
611
|
|
|
606
|
-
describe("Loop.
|
|
612
|
+
describe("Loop.loopOver", () => {
|
|
607
613
|
it("runs a multi-turn inner loop per input until the body emits stop", async () => {
|
|
608
614
|
// Per input: emit (input + turnsSoFar) twice, then stop. State counts
|
|
609
615
|
// total turns ACROSS inputs. Demonstrates that `next(s)` continues with
|
|
610
616
|
// the SAME input, multiple times per input — not one body call per item.
|
|
611
617
|
const result = await Effect.runPromise(
|
|
612
618
|
Stream.fromIterable(["a", "b"]).pipe(
|
|
613
|
-
|
|
614
|
-
if (turns >= 2 * (input === "a" ? 1 : 2)) return
|
|
615
|
-
return Stream.
|
|
619
|
+
loopOver(0, (turns: number, input: string) => {
|
|
620
|
+
if (turns >= 2 * (input === "a" ? 1 : 2)) return stop()
|
|
621
|
+
return Stream.succeed(value(`${input}:${turns}`)).pipe(Stream.concat(next(turns + 1)))
|
|
616
622
|
}),
|
|
617
623
|
Stream.runCollect,
|
|
618
624
|
),
|
|
@@ -625,15 +631,14 @@ describe("Loop.loopFrom", () => {
|
|
|
625
631
|
|
|
626
632
|
it("threads state across inputs (audio-pipeline shape)", async () => {
|
|
627
633
|
// History accumulates across inputs. Each input emits its joined view of
|
|
628
|
-
// history+input, then `
|
|
634
|
+
// history+input, then `stop(state)` ends the inner loop AND carries the
|
|
629
635
|
// updated history to the next input.
|
|
630
636
|
const result = await Effect.runPromise(
|
|
631
637
|
Stream.fromIterable(["x", "y", "z"]).pipe(
|
|
632
|
-
|
|
633
|
-
Stream.
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
]),
|
|
638
|
+
loopOver([] as ReadonlyArray<string>, (history: ReadonlyArray<string>, input: string) =>
|
|
639
|
+
Stream.succeed(value([...history, input].join(","))).pipe(
|
|
640
|
+
Stream.concat(stop([...history, input])),
|
|
641
|
+
),
|
|
637
642
|
),
|
|
638
643
|
Stream.runCollect,
|
|
639
644
|
),
|
|
@@ -653,27 +658,24 @@ describe("Loop.loopFrom", () => {
|
|
|
653
658
|
|
|
654
659
|
const result = await Effect.runPromise(
|
|
655
660
|
Stream.fromIterable(["doc1", "doc2"]).pipe(
|
|
656
|
-
|
|
661
|
+
loopOver({ turn: 0, totalTurns: 0 } as State, (state, doc: string) => {
|
|
657
662
|
// Each document runs three turns then stops.
|
|
658
663
|
if (state.turn === 0) {
|
|
659
|
-
return Stream.
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
])
|
|
664
|
+
return Stream.succeed(value<Turn>({ kind: "text", doc, text: "thinking" })).pipe(
|
|
665
|
+
Stream.concat(next({ turn: 1, totalTurns: state.totalTurns + 1 })),
|
|
666
|
+
)
|
|
663
667
|
}
|
|
664
668
|
if (state.turn === 1) {
|
|
665
|
-
return Stream.
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
])
|
|
669
|
+
return Stream.succeed(value<Turn>({ kind: "tool", doc, tool: "search" })).pipe(
|
|
670
|
+
Stream.concat(next({ turn: 2, totalTurns: state.totalTurns + 1 })),
|
|
671
|
+
)
|
|
669
672
|
}
|
|
670
|
-
// Final turn — `
|
|
673
|
+
// Final turn — `stop(state)` emits the final value, advances state
|
|
671
674
|
// (reset turn to 0 for the next document, bump totalTurns), and
|
|
672
675
|
// ends this document's inner loop in one shot.
|
|
673
|
-
return Stream.
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
])
|
|
676
|
+
return Stream.succeed(value<Turn>({ kind: "text", doc, text: "final" })).pipe(
|
|
677
|
+
Stream.concat(stop({ turn: 0, totalTurns: state.totalTurns + 1 })),
|
|
678
|
+
)
|
|
677
679
|
}),
|
|
678
680
|
Stream.runCollect,
|
|
679
681
|
),
|
|
@@ -693,10 +695,10 @@ describe("Loop.loopFrom", () => {
|
|
|
693
695
|
// Single-input case: body advances via `next` then stops cleanly.
|
|
694
696
|
const result = await Effect.runPromise(
|
|
695
697
|
Stream.fromIterable(["only"]).pipe(
|
|
696
|
-
|
|
698
|
+
loopOver(0, (turns: number, input: string) =>
|
|
697
699
|
turns >= 2
|
|
698
|
-
?
|
|
699
|
-
: Stream.
|
|
700
|
+
? stop()
|
|
701
|
+
: Stream.succeed(value(`${input}:${turns}`)).pipe(Stream.concat(next(turns + 1))),
|
|
700
702
|
),
|
|
701
703
|
Stream.runCollect,
|
|
702
704
|
),
|
|
@@ -705,14 +707,14 @@ describe("Loop.loopFrom", () => {
|
|
|
705
707
|
expect(result).toEqual(["only:0", "only:1"])
|
|
706
708
|
})
|
|
707
709
|
|
|
708
|
-
it("body's `stop` advances to the next input (does NOT halt the whole stream)", async () => {
|
|
710
|
+
it("body's `stop()` advances to the next input (does NOT halt the whole stream)", async () => {
|
|
709
711
|
// Three inputs, body always stops on its first emission. All three
|
|
710
|
-
// are processed — `stop` is per-input, not global. To halt the whole
|
|
712
|
+
// are processed — `stop()` is per-input, not global. To halt the whole
|
|
711
713
|
// stream, end the INPUT stream upstream.
|
|
712
714
|
const result = await Effect.runPromise(
|
|
713
715
|
Stream.fromIterable([1, 2, 3]).pipe(
|
|
714
|
-
|
|
715
|
-
Stream.
|
|
716
|
+
loopOver(0, (_state: number, input: number) =>
|
|
717
|
+
Stream.succeed(value(input * 10)).pipe(Stream.concat(stop())),
|
|
716
718
|
),
|
|
717
719
|
Stream.runCollect,
|
|
718
720
|
),
|
|
@@ -725,10 +727,10 @@ describe("Loop.loopFrom", () => {
|
|
|
725
727
|
const inputs = Stream.fromIterable([1, 2])
|
|
726
728
|
const result = await Effect.runPromise(
|
|
727
729
|
Stream.runCollect(
|
|
728
|
-
|
|
730
|
+
loopOver(inputs, 0, (state: number, input: number) =>
|
|
729
731
|
state >= input
|
|
730
|
-
?
|
|
731
|
-
: Stream.
|
|
732
|
+
? stop()
|
|
733
|
+
: Stream.succeed(value(state + input)).pipe(Stream.concat(next(state + 1))),
|
|
732
734
|
),
|
|
733
735
|
),
|
|
734
736
|
)
|
|
@@ -741,11 +743,11 @@ describe("Loop.loopFrom", () => {
|
|
|
741
743
|
it("supports Effect-returning bodies (parity with loop)", async () => {
|
|
742
744
|
const result = await Effect.runPromise(
|
|
743
745
|
Stream.fromIterable(["a"]).pipe(
|
|
744
|
-
|
|
746
|
+
loopOver(0, (turns: number, input: string) =>
|
|
745
747
|
Effect.gen(function* () {
|
|
746
748
|
const cur = yield* Effect.succeed(turns)
|
|
747
|
-
if (cur >= 2) return
|
|
748
|
-
return Stream.
|
|
749
|
+
if (cur >= 2) return stop()
|
|
750
|
+
return Stream.succeed(value(`${input}:${cur}`)).pipe(Stream.concat(next(cur + 1)))
|
|
749
751
|
}),
|
|
750
752
|
),
|
|
751
753
|
Stream.runCollect,
|
|
@@ -758,7 +760,7 @@ describe("Loop.loopFrom", () => {
|
|
|
758
760
|
it("type: data-last (pipe) form preserves the body's E channel", () => {
|
|
759
761
|
const result = pipe(
|
|
760
762
|
Stream.fromIterable([1]),
|
|
761
|
-
|
|
763
|
+
loopOver(0, (_state: number, _input: number) =>
|
|
762
764
|
Stream.fail(new AiError.RateLimited({ provider: "test", raw: null })),
|
|
763
765
|
),
|
|
764
766
|
)
|
|
@@ -768,7 +770,7 @@ describe("Loop.loopFrom", () => {
|
|
|
768
770
|
|
|
769
771
|
it("type: data-first form preserves the body's E channel and unifies with input's E", () => {
|
|
770
772
|
const input: Stream.Stream<number, Error> = Stream.fail(new Error("boom"))
|
|
771
|
-
const result =
|
|
773
|
+
const result = loopOver(input, 0, (_state: number, _i: number) =>
|
|
772
774
|
Stream.fail(new AiError.RateLimited({ provider: "test", raw: null })),
|
|
773
775
|
)
|
|
774
776
|
type E = typeof result extends Stream.Stream<unknown, infer X, unknown> ? X : never
|