@effect-uai/core 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{Image-BZmKfIdq.d.mts → Image-C4vuBmu0.d.mts} +11 -11
- package/dist/{Image-BZmKfIdq.d.mts.map → Image-C4vuBmu0.d.mts.map} +1 -1
- package/dist/{Items-BnIduEV2.d.mts → Items-BWNu6VGp.d.mts} +116 -90
- package/dist/Items-BWNu6VGp.d.mts.map +1 -0
- package/dist/{Tool-2lC_XMth.d.mts → Tool-CXFHCQrT.d.mts} +42 -9
- package/dist/Tool-CXFHCQrT.d.mts.map +1 -0
- package/dist/{ToolEvent-cWZ2BnbS.d.mts → ToolEvent-nVijCrvm.d.mts} +2 -2
- package/dist/{ToolEvent-cWZ2BnbS.d.mts.map → ToolEvent-nVijCrvm.d.mts.map} +1 -1
- package/dist/{ToolResult-CL4hK_9z.d.mts → ToolResult-CQqKkcDa.d.mts} +2 -2
- package/dist/{ToolResult-CL4hK_9z.d.mts.map → ToolResult-CQqKkcDa.d.mts.map} +1 -1
- package/dist/{Toolkit-Dr9l_hNy.d.mts → Toolkit-BYPwwu-j.d.mts} +6 -6
- package/dist/Toolkit-BYPwwu-j.d.mts.map +1 -0
- package/dist/browser/Browser.mjs +4 -4
- package/dist/browser/Browser.mjs.map +1 -1
- package/dist/browser/BrowserTool.d.mts +34 -33
- package/dist/browser/BrowserTool.d.mts.map +1 -1
- package/dist/{dist-DWgRbDN3.mjs → dist-C0m0QnUP.mjs} +71 -30
- package/dist/dist-C0m0QnUP.mjs.map +1 -0
- package/dist/{dist-B04dlFHg.mjs → dist-CcRV3-A5.mjs} +8 -8
- package/dist/{dist-B04dlFHg.mjs.map → dist-CcRV3-A5.mjs.map} +1 -1
- package/dist/domain/Citation.d.mts +60 -0
- package/dist/domain/Citation.d.mts.map +1 -0
- package/dist/domain/Citation.mjs +7 -0
- package/dist/domain/Citation.mjs.map +1 -0
- package/dist/domain/Image.d.mts +1 -1
- package/dist/domain/Items.d.mts +1 -1
- package/dist/domain/Items.mjs +13 -3
- package/dist/domain/Items.mjs.map +1 -1
- package/dist/domain/Research.d.mts +37 -0
- package/dist/domain/Research.d.mts.map +1 -0
- package/dist/domain/Research.mjs +7 -0
- package/dist/domain/Research.mjs.map +1 -0
- package/dist/domain/Turn.d.mts +91 -9
- package/dist/domain/Turn.d.mts.map +1 -1
- package/dist/domain/Turn.mjs +8 -1
- package/dist/domain/Turn.mjs.map +1 -1
- package/dist/domain/Turn.test.mjs +1 -1
- package/dist/embedding-model/Embedding.d.mts +1 -1
- package/dist/embedding-model/EmbeddingModel.mjs +2 -2
- package/dist/embedding-model/EmbeddingModel.mjs.map +1 -1
- package/dist/embedding-model/EmbeddingModel.test.mjs +1 -1
- package/dist/index.d.mts +12 -8
- package/dist/index.mjs +5 -1
- package/dist/job/Job.d.mts +80 -0
- package/dist/job/Job.d.mts.map +1 -0
- package/dist/job/Job.mjs +49 -0
- package/dist/job/Job.mjs.map +1 -0
- package/dist/language-model/LanguageModel.d.mts +2 -2
- package/dist/language-model/LanguageModel.mjs +2 -2
- package/dist/language-model/LanguageModel.mjs.map +1 -1
- package/dist/language-model/LanguageModel.test.mjs +1 -1
- package/dist/loop/Loop.test.mjs +1 -1
- package/dist/music-generator/MusicGenerator.mjs +4 -4
- package/dist/music-generator/MusicGenerator.mjs.map +1 -1
- package/dist/music-generator/MusicGenerator.test.mjs +1 -1
- package/dist/observability/Metrics.d.mts +1 -1
- package/dist/observability/Metrics.test.mjs +2 -2
- package/dist/observability/Telemetry.test.mjs +2 -2
- package/dist/research/DeepResearch.d.mts +71 -0
- package/dist/research/DeepResearch.d.mts.map +1 -0
- package/dist/research/DeepResearch.mjs +61 -0
- package/dist/research/DeepResearch.mjs.map +1 -0
- package/dist/retry/Retry.test.mjs +1 -1
- package/dist/sandbox/Sandbox.mjs +18 -18
- package/dist/sandbox/Sandbox.mjs.map +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.mjs +8 -8
- package/dist/speech-synthesizer/SpeechSynthesizer.mjs.map +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +1 -1
- package/dist/streaming/JSONL.d.mts +3 -1
- package/dist/streaming/JSONL.d.mts.map +1 -1
- package/dist/streaming/JSONL.mjs +5 -11
- package/dist/streaming/JSONL.mjs.map +1 -1
- package/dist/streaming/JSONL.test.mjs +10 -1
- package/dist/streaming/JSONL.test.mjs.map +1 -1
- package/dist/streaming/SSE.d.mts +5 -3
- package/dist/streaming/SSE.d.mts.map +1 -1
- package/dist/streaming/SSE.mjs +28 -38
- package/dist/streaming/SSE.mjs.map +1 -1
- package/dist/streaming/SSE.test.mjs +54 -4
- package/dist/streaming/SSE.test.mjs.map +1 -1
- package/dist/structured-format/StructuredFormat.test.mjs +1 -1
- package/dist/testing/MockProvider.d.mts +1 -1
- package/dist/tool/Approval.d.mts +3 -3
- package/dist/tool/Approval.test.mjs +1 -1
- package/dist/tool/HistoryCheck.d.mts +2 -2
- package/dist/tool/Tool.d.mts +2 -2
- package/dist/tool/Tool.mjs +27 -7
- package/dist/tool/Tool.mjs.map +1 -1
- package/dist/tool/Tool.test.mjs +127 -3
- package/dist/tool/Tool.test.mjs.map +1 -1
- package/dist/tool/ToolEvent.d.mts +1 -1
- package/dist/tool/ToolResult.d.mts +1 -1
- package/dist/tool/Toolkit.d.mts +1 -1
- package/dist/tool/Toolkit.mjs.map +1 -1
- package/dist/tool/Toolkit.test.mjs +46 -2
- package/dist/tool/Toolkit.test.mjs.map +1 -1
- package/dist/transcriber/Transcriber.mjs +3 -3
- package/dist/transcriber/Transcriber.mjs.map +1 -1
- package/dist/transcriber/Transcriber.test.mjs +1 -1
- package/dist/web-read/WebRead.mjs +1 -1
- package/dist/web-read/WebRead.mjs.map +1 -1
- package/dist/web-read/WebReadTool.d.mts +1 -1
- package/dist/web-search/WebSearch.mjs +1 -1
- package/dist/web-search/WebSearch.mjs.map +1 -1
- package/dist/web-search/WebSearchTool.d.mts +1 -1
- package/package.json +19 -3
- package/src/browser/Browser.ts +4 -4
- package/src/domain/Citation.ts +64 -0
- package/src/domain/Items.ts +14 -4
- package/src/domain/Research.ts +42 -0
- package/src/domain/Turn.ts +26 -0
- package/src/embedding-model/EmbeddingModel.ts +2 -2
- package/src/index.ts +4 -0
- package/src/job/Job.ts +125 -0
- package/src/language-model/LanguageModel.ts +2 -2
- package/src/music-generator/MusicGenerator.ts +4 -4
- package/src/research/DeepResearch.ts +137 -0
- package/src/sandbox/Sandbox.ts +18 -18
- package/src/speech-synthesizer/SpeechSynthesizer.ts +8 -8
- package/src/streaming/JSONL.test.ts +19 -0
- package/src/streaming/JSONL.ts +6 -38
- package/src/streaming/SSE.test.ts +86 -3
- package/src/streaming/SSE.ts +36 -80
- package/src/tool/Tool.test.ts +151 -1
- package/src/tool/Tool.ts +120 -22
- package/src/tool/Toolkit.test.ts +60 -0
- package/src/tool/Toolkit.ts +2 -2
- package/src/transcriber/Transcriber.ts +3 -3
- package/src/web-read/WebRead.ts +1 -1
- package/src/web-search/WebSearch.ts +1 -1
- package/dist/Items-BnIduEV2.d.mts.map +0 -1
- package/dist/Tool-2lC_XMth.d.mts.map +0 -1
- package/dist/Toolkit-Dr9l_hNy.d.mts.map +0 -1
- package/dist/dist-DWgRbDN3.mjs.map +0 -1
package/src/sandbox/Sandbox.ts
CHANGED
|
@@ -520,7 +520,7 @@ export class SandboxPortExposure extends Context.Service<SandboxPortExposure, vo
|
|
|
520
520
|
export const create = (
|
|
521
521
|
request: CommonCreateRequest,
|
|
522
522
|
): Effect.Effect<SandboxInstance, SandboxError.SandboxError, Sandbox | Scope.Scope> =>
|
|
523
|
-
Effect.flatMap(Sandbox
|
|
523
|
+
Effect.flatMap(Sandbox, (s) => s.create(request))
|
|
524
524
|
|
|
525
525
|
/**
|
|
526
526
|
* Re-acquire an existing sandbox by id. Same scope semantics as
|
|
@@ -529,14 +529,14 @@ export const create = (
|
|
|
529
529
|
export const attach = (
|
|
530
530
|
id: SandboxId,
|
|
531
531
|
): Effect.Effect<SandboxInstance, SandboxError.SandboxError, Sandbox | Scope.Scope> =>
|
|
532
|
-
Effect.flatMap(Sandbox
|
|
532
|
+
Effect.flatMap(Sandbox, (s) => s.attach(id))
|
|
533
533
|
|
|
534
534
|
/** Enumerate sandboxes for the configured account / project. */
|
|
535
535
|
export const list: Effect.Effect<
|
|
536
536
|
ReadonlyArray<SandboxRef>,
|
|
537
537
|
SandboxError.SandboxError,
|
|
538
538
|
Sandbox
|
|
539
|
-
> = Effect.flatMap(Sandbox
|
|
539
|
+
> = Effect.flatMap(Sandbox, (s) => s.list)
|
|
540
540
|
|
|
541
541
|
/**
|
|
542
542
|
* Escape hatch for destroying a sandbox from outside its owning
|
|
@@ -544,7 +544,7 @@ export const list: Effect.Effect<
|
|
|
544
544
|
* automatically.
|
|
545
545
|
*/
|
|
546
546
|
export const destroy = (id: SandboxId): Effect.Effect<void, SandboxError.SandboxError, Sandbox> =>
|
|
547
|
-
Effect.flatMap(Sandbox
|
|
547
|
+
Effect.flatMap(Sandbox, (s) => s.destroy(id))
|
|
548
548
|
|
|
549
549
|
/**
|
|
550
550
|
* Create a snapshot of a live sandbox. Requires
|
|
@@ -557,8 +557,8 @@ export const snapshot = (
|
|
|
557
557
|
name?: string,
|
|
558
558
|
): Effect.Effect<SnapshotId, SandboxError.SandboxError, Sandbox | SandboxSnapshots> =>
|
|
559
559
|
Effect.gen(function* () {
|
|
560
|
-
const s = yield* Sandbox
|
|
561
|
-
yield* SandboxSnapshots
|
|
560
|
+
const s = yield* Sandbox
|
|
561
|
+
yield* SandboxSnapshots
|
|
562
562
|
return yield* s.snapshots.create(from, name)
|
|
563
563
|
})
|
|
564
564
|
|
|
@@ -570,8 +570,8 @@ export const createVolume = (
|
|
|
570
570
|
options?: { readonly quotaBytes?: number },
|
|
571
571
|
): Effect.Effect<VolumeId, SandboxError.SandboxError, Sandbox | SandboxVolumes> =>
|
|
572
572
|
Effect.gen(function* () {
|
|
573
|
-
const s = yield* Sandbox
|
|
574
|
-
yield* SandboxVolumes
|
|
573
|
+
const s = yield* Sandbox
|
|
574
|
+
yield* SandboxVolumes
|
|
575
575
|
return yield* s.volumes.create(name, options)
|
|
576
576
|
})
|
|
577
577
|
|
|
@@ -580,8 +580,8 @@ export const destroyVolume = (
|
|
|
580
580
|
id: VolumeId,
|
|
581
581
|
): Effect.Effect<void, SandboxError.SandboxError, Sandbox | SandboxVolumes> =>
|
|
582
582
|
Effect.gen(function* () {
|
|
583
|
-
const s = yield* Sandbox
|
|
584
|
-
yield* SandboxVolumes
|
|
583
|
+
const s = yield* Sandbox
|
|
584
|
+
yield* SandboxVolumes
|
|
585
585
|
return yield* s.volumes.destroy(id)
|
|
586
586
|
})
|
|
587
587
|
|
|
@@ -591,8 +591,8 @@ export const listVolumes: Effect.Effect<
|
|
|
591
591
|
SandboxError.SandboxError,
|
|
592
592
|
Sandbox | SandboxVolumes
|
|
593
593
|
> = Effect.gen(function* () {
|
|
594
|
-
const s = yield* Sandbox
|
|
595
|
-
yield* SandboxVolumes
|
|
594
|
+
const s = yield* Sandbox
|
|
595
|
+
yield* SandboxVolumes
|
|
596
596
|
return yield* s.volumes.list
|
|
597
597
|
})
|
|
598
598
|
|
|
@@ -601,8 +601,8 @@ export const destroySnapshot = (
|
|
|
601
601
|
id: SnapshotId,
|
|
602
602
|
): Effect.Effect<void, SandboxError.SandboxError, Sandbox | SandboxSnapshots> =>
|
|
603
603
|
Effect.gen(function* () {
|
|
604
|
-
const s = yield* Sandbox
|
|
605
|
-
yield* SandboxSnapshots
|
|
604
|
+
const s = yield* Sandbox
|
|
605
|
+
yield* SandboxSnapshots
|
|
606
606
|
return yield* s.snapshots.destroy(id)
|
|
607
607
|
})
|
|
608
608
|
|
|
@@ -612,8 +612,8 @@ export const listSnapshots: Effect.Effect<
|
|
|
612
612
|
SandboxError.SandboxError,
|
|
613
613
|
Sandbox | SandboxSnapshots
|
|
614
614
|
> = Effect.gen(function* () {
|
|
615
|
-
const s = yield* Sandbox
|
|
616
|
-
yield* SandboxSnapshots
|
|
615
|
+
const s = yield* Sandbox
|
|
616
|
+
yield* SandboxSnapshots
|
|
617
617
|
return yield* s.snapshots.list
|
|
618
618
|
})
|
|
619
619
|
|
|
@@ -633,7 +633,7 @@ export const exposePort = (
|
|
|
633
633
|
Sandbox | SandboxPortExposure
|
|
634
634
|
> =>
|
|
635
635
|
Effect.gen(function* () {
|
|
636
|
-
const s = yield* Sandbox
|
|
637
|
-
yield* SandboxPortExposure
|
|
636
|
+
const s = yield* Sandbox
|
|
637
|
+
yield* SandboxPortExposure
|
|
638
638
|
return yield* s.ports.expose(instance, port)
|
|
639
639
|
})
|
|
@@ -169,13 +169,13 @@ export class MultiSpeakerTts extends Context.Service<MultiSpeakerTts, void>()(
|
|
|
169
169
|
export const synthesize = (
|
|
170
170
|
request: CommonSynthesizeRequest,
|
|
171
171
|
): Effect.Effect<AudioBlob, AiError.AiError, SpeechSynthesizer> =>
|
|
172
|
-
Effect.flatMap(SpeechSynthesizer
|
|
172
|
+
Effect.flatMap(SpeechSynthesizer, (s) => s.synthesize(request))
|
|
173
173
|
|
|
174
174
|
/** Full text in, audio chunks out. */
|
|
175
175
|
export const streamSynthesis = (
|
|
176
176
|
request: CommonSynthesizeRequest,
|
|
177
177
|
): Stream.Stream<AudioChunk, AiError.AiError, SpeechSynthesizer> =>
|
|
178
|
-
Stream.unwrap(Effect.map(SpeechSynthesizer
|
|
178
|
+
Stream.unwrap(Effect.map(SpeechSynthesizer, (s) => s.streamSynthesis(request)))
|
|
179
179
|
|
|
180
180
|
/**
|
|
181
181
|
* Incremental synthesis. Dual-arity: pipeable (data-last) and direct
|
|
@@ -205,8 +205,8 @@ export const streamSynthesisFrom: {
|
|
|
205
205
|
<E, R>(textIn: Stream.Stream<string, E, R>, request: CommonStreamSynthesizeRequest) =>
|
|
206
206
|
Stream.unwrap(
|
|
207
207
|
Effect.gen(function* () {
|
|
208
|
-
const s = yield* SpeechSynthesizer
|
|
209
|
-
yield* TtsIncrementalText
|
|
208
|
+
const s = yield* SpeechSynthesizer
|
|
209
|
+
yield* TtsIncrementalText
|
|
210
210
|
return s.streamSynthesisFrom(textIn, request)
|
|
211
211
|
}),
|
|
212
212
|
),
|
|
@@ -220,8 +220,8 @@ export const synthesizeDialogue = (
|
|
|
220
220
|
request: CommonSynthesizeDialogueRequest,
|
|
221
221
|
): Effect.Effect<AudioBlob, AiError.AiError, SpeechSynthesizer | MultiSpeakerTts> =>
|
|
222
222
|
Effect.gen(function* () {
|
|
223
|
-
const s = yield* SpeechSynthesizer
|
|
224
|
-
yield* MultiSpeakerTts
|
|
223
|
+
const s = yield* SpeechSynthesizer
|
|
224
|
+
yield* MultiSpeakerTts
|
|
225
225
|
return yield* s.synthesizeDialogue(request)
|
|
226
226
|
})
|
|
227
227
|
|
|
@@ -233,8 +233,8 @@ export const streamSynthesizeDialogue = (
|
|
|
233
233
|
): Stream.Stream<AudioChunk, AiError.AiError, SpeechSynthesizer | MultiSpeakerTts> =>
|
|
234
234
|
Stream.unwrap(
|
|
235
235
|
Effect.gen(function* () {
|
|
236
|
-
const s = yield* SpeechSynthesizer
|
|
237
|
-
yield* MultiSpeakerTts
|
|
236
|
+
const s = yield* SpeechSynthesizer
|
|
237
|
+
yield* MultiSpeakerTts
|
|
238
238
|
return s.streamSynthesizeDialogue(request)
|
|
239
239
|
}),
|
|
240
240
|
)
|
|
@@ -8,6 +8,18 @@ const bytesOf = (...chunks: ReadonlyArray<string>) =>
|
|
|
8
8
|
|
|
9
9
|
const collect = <A, E>(s: Stream.Stream<A, E>) => Effect.runPromise(Stream.runCollect(s))
|
|
10
10
|
|
|
11
|
+
const runUntilEnd = <A, E>(s: Stream.Stream<A, E>) =>
|
|
12
|
+
Effect.runPromise(
|
|
13
|
+
s.pipe(
|
|
14
|
+
Stream.result,
|
|
15
|
+
Stream.runCollect,
|
|
16
|
+
Effect.map((results) => ({
|
|
17
|
+
seen: results.filter(Result.isSuccess).map((r) => r.success),
|
|
18
|
+
failures: results.filter(Result.isFailure).map((r) => r.failure),
|
|
19
|
+
})),
|
|
20
|
+
),
|
|
21
|
+
)
|
|
22
|
+
|
|
11
23
|
const collectResult = <A, E>(s: Stream.Stream<A, E>) =>
|
|
12
24
|
Effect.runPromise(Effect.result(Stream.runCollect(s)))
|
|
13
25
|
|
|
@@ -33,6 +45,13 @@ describe("JSONL.fromBytes", () => {
|
|
|
33
45
|
const out = await collect(JSONL.fromBytes(bytesOf("a\n\n\nb\n")))
|
|
34
46
|
expect(out).toEqual(["a", "b"])
|
|
35
47
|
})
|
|
48
|
+
|
|
49
|
+
it("discards a buffered partial line when the upstream fails mid-stream", async () => {
|
|
50
|
+
const failing = Stream.concat(bytesOf('{"n":1}\n{"n":2'), Stream.fail("boom" as const))
|
|
51
|
+
const { seen, failures } = await runUntilEnd(JSONL.fromBytes(failing))
|
|
52
|
+
expect(seen).toEqual(['{"n":1}'])
|
|
53
|
+
expect(failures).toEqual(["boom"])
|
|
54
|
+
})
|
|
36
55
|
})
|
|
37
56
|
|
|
38
57
|
describe("JSONL.parse", () => {
|
package/src/streaming/JSONL.ts
CHANGED
|
@@ -1,58 +1,26 @@
|
|
|
1
|
-
import { Data, Effect, Schema, Stream } from "effect"
|
|
1
|
+
import { Channel, Data, Effect, Schema, Stream } from "effect"
|
|
2
2
|
|
|
3
3
|
export class JsonParseError extends Data.TaggedError("JsonParseError")<{
|
|
4
4
|
readonly line: string
|
|
5
5
|
readonly cause: unknown
|
|
6
6
|
}> {}
|
|
7
7
|
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
// Generic stream helpers (kept module-local; see SSE.ts for the same shape).
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
|
|
12
|
-
const decodeText = <E, R>(self: Stream.Stream<Uint8Array, E, R>): Stream.Stream<string, E, R> =>
|
|
13
|
-
self.pipe(
|
|
14
|
-
Stream.mapAccum(
|
|
15
|
-
(): TextDecoder => new TextDecoder("utf-8"),
|
|
16
|
-
(decoder, chunk: Uint8Array) => [decoder, [decoder.decode(chunk, { stream: true })]] as const,
|
|
17
|
-
{
|
|
18
|
-
onHalt: (decoder: TextDecoder) => {
|
|
19
|
-
const tail = decoder.decode()
|
|
20
|
-
return tail.length > 0 ? [tail] : []
|
|
21
|
-
},
|
|
22
|
-
},
|
|
23
|
-
),
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
const splitOn =
|
|
27
|
-
(separator: string) =>
|
|
28
|
-
<E, R>(self: Stream.Stream<string, E, R>): Stream.Stream<string, E, R> =>
|
|
29
|
-
self.pipe(
|
|
30
|
-
Stream.mapAccum(
|
|
31
|
-
(): string => "",
|
|
32
|
-
(buffer, chunk: string) => {
|
|
33
|
-
const parts = (buffer + chunk).split(separator)
|
|
34
|
-
const tail = parts[parts.length - 1] ?? ""
|
|
35
|
-
return [tail, parts.slice(0, -1)] as const
|
|
36
|
-
},
|
|
37
|
-
{ onHalt: (tail: string) => (tail.length > 0 ? [tail] : []) },
|
|
38
|
-
),
|
|
39
|
-
)
|
|
40
|
-
|
|
41
8
|
// ---------------------------------------------------------------------------
|
|
42
9
|
// Public API
|
|
43
10
|
// ---------------------------------------------------------------------------
|
|
44
11
|
|
|
45
12
|
/**
|
|
46
13
|
* Decode a `Stream<Uint8Array>` into a `Stream<string>` of newline-delimited
|
|
47
|
-
* lines. Empty lines are skipped. Buffers across chunk boundaries
|
|
14
|
+
* lines. Empty lines are skipped. Buffers across chunk boundaries; a final
|
|
15
|
+
* line without a trailing newline is emitted at clean stream end, while a
|
|
16
|
+
* buffered partial line is discarded if the stream fails.
|
|
48
17
|
*/
|
|
49
18
|
export const fromBytes = <E, R>(
|
|
50
19
|
self: Stream.Stream<Uint8Array, E, R>,
|
|
51
20
|
): Stream.Stream<string, E, R> =>
|
|
52
21
|
self.pipe(
|
|
53
|
-
decodeText,
|
|
54
|
-
Stream.
|
|
55
|
-
splitOn("\n"),
|
|
22
|
+
Stream.decodeText,
|
|
23
|
+
Stream.pipeThroughChannel(Channel.splitLines()),
|
|
56
24
|
Stream.filter((line) => line.length > 0),
|
|
57
25
|
)
|
|
58
26
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect, Stream } from "effect"
|
|
1
|
+
import { Cause, Deferred, Effect, Exit, Fiber, Result, Stream } from "effect"
|
|
2
2
|
import { describe, expect, it } from "vitest"
|
|
3
3
|
import * as SSE from "./SSE.js"
|
|
4
4
|
|
|
@@ -8,6 +8,21 @@ const bytesOf = (...chunks: ReadonlyArray<string>) =>
|
|
|
8
8
|
|
|
9
9
|
const collect = <A, E>(s: Stream.Stream<A, E>) => Effect.runPromise(Stream.runCollect(s))
|
|
10
10
|
|
|
11
|
+
const runUntilEnd = <A, E>(s: Stream.Stream<A, E>) =>
|
|
12
|
+
Effect.runPromise(
|
|
13
|
+
s.pipe(
|
|
14
|
+
Stream.result,
|
|
15
|
+
Stream.runCollect,
|
|
16
|
+
Effect.map((results) => ({
|
|
17
|
+
seen: results.filter(Result.isSuccess).map((r) => r.success),
|
|
18
|
+
failures: results.filter(Result.isFailure).map((r) => r.failure),
|
|
19
|
+
})),
|
|
20
|
+
),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
const failuresOf = (exit: Exit.Exit<unknown, unknown>): ReadonlyArray<unknown> =>
|
|
24
|
+
Exit.isFailure(exit) ? exit.cause.reasons.filter(Cause.isFailReason).map((r) => r.error) : []
|
|
25
|
+
|
|
11
26
|
describe("SSE.fromBytes", () => {
|
|
12
27
|
it("parses a single complete event", async () => {
|
|
13
28
|
const out = await collect(SSE.fromBytes(bytesOf("event: foo\ndata: hello\n\n")))
|
|
@@ -39,9 +54,77 @@ describe("SSE.fromBytes", () => {
|
|
|
39
54
|
expect(out).toEqual([{ id: "42", data: "x" }])
|
|
40
55
|
})
|
|
41
56
|
|
|
42
|
-
it("
|
|
57
|
+
it("parses a data field with no space after the colon", async () => {
|
|
58
|
+
const out = await collect(SSE.fromBytes(bytesOf("data:x\n\n")))
|
|
59
|
+
expect(out).toEqual([{ data: "x" }])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("ignores an id containing a NUL character", async () => {
|
|
63
|
+
const out = await collect(SSE.fromBytes(bytesOf("id: a\u0000b\ndata: x\n\n")))
|
|
64
|
+
expect(out).toEqual([{ data: "x" }])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("discards a trailing event without a closing blank line", async () => {
|
|
43
68
|
const out = await collect(SSE.fromBytes(bytesOf("data: tail")))
|
|
44
|
-
expect(out).toEqual([
|
|
69
|
+
expect(out).toEqual([])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("discards buffered partial data when the upstream fails mid-stream", async () => {
|
|
73
|
+
const failing = Stream.concat(
|
|
74
|
+
bytesOf("data: good\n\ndata: partial-tr"),
|
|
75
|
+
Stream.fail("boom" as const),
|
|
76
|
+
)
|
|
77
|
+
const { seen, failures } = await runUntilEnd(SSE.fromBytes(failing))
|
|
78
|
+
expect(seen).toEqual([{ data: "good" }])
|
|
79
|
+
expect(failures).toEqual(["boom"])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("discards a partial multi-byte char in the text decoder when the upstream fails", async () => {
|
|
83
|
+
const partialSquid = enc.encode("data: 🦑\n\n").slice(0, 8)
|
|
84
|
+
const failing = Stream.concat(
|
|
85
|
+
Stream.fromIterable([enc.encode("data: ok\n\n"), partialSquid]),
|
|
86
|
+
Stream.fail("boom" as const),
|
|
87
|
+
)
|
|
88
|
+
const { seen, failures } = await runUntilEnd(SSE.fromBytes(failing))
|
|
89
|
+
expect(seen).toEqual([{ data: "ok" }])
|
|
90
|
+
expect(failures).toEqual(["boom"])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("discards buffered partial data when the stream is interrupted", async () => {
|
|
94
|
+
const { seen, exit } = await Effect.runPromise(
|
|
95
|
+
Effect.gen(function* () {
|
|
96
|
+
const seen: Array<SSE.Event> = []
|
|
97
|
+
const latch = yield* Deferred.make<void>()
|
|
98
|
+
const hanging = Stream.concat(bytesOf("data: good\n\ndata: partial"), Stream.never)
|
|
99
|
+
const fiber = yield* Stream.runForEach(SSE.fromBytes(hanging), (e) =>
|
|
100
|
+
Effect.sync(() => {
|
|
101
|
+
seen.push(e)
|
|
102
|
+
}).pipe(Effect.andThen(Deferred.succeed(latch, void 0))),
|
|
103
|
+
).pipe(Effect.forkChild)
|
|
104
|
+
yield* Deferred.await(latch)
|
|
105
|
+
yield* Fiber.interrupt(fiber)
|
|
106
|
+
const exit = yield* Fiber.await(fiber)
|
|
107
|
+
return { seen, exit }
|
|
108
|
+
}),
|
|
109
|
+
)
|
|
110
|
+
expect(seen).toEqual([{ data: "good" }])
|
|
111
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
112
|
+
expect(failuresOf(exit)).toEqual([])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("ignores retry directives", async () => {
|
|
116
|
+
const out = await collect(SSE.fromBytes(bytesOf("retry: 3000\n\ndata: a\n\n")))
|
|
117
|
+
expect(out).toEqual([{ data: "a" }])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("treats an explicit 'event: message' as the default event", async () => {
|
|
121
|
+
const out = await collect(SSE.fromBytes(bytesOf("event: message\ndata: x\n\n")))
|
|
122
|
+
expect(out).toEqual([{ data: "x" }])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it("does not dispatch events with no data", async () => {
|
|
126
|
+
const out = await collect(SSE.fromBytes(bytesOf("event: ping\n\ndata: x\n\n")))
|
|
127
|
+
expect(out).toEqual([{ data: "x" }])
|
|
45
128
|
})
|
|
46
129
|
|
|
47
130
|
it("ignores empty blocks between events", async () => {
|
package/src/streaming/SSE.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Stream } from "effect"
|
|
2
|
+
import { Sse } from "effect/unstable/encoding"
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* One Server-Sent Event. Fields per the WHATWG spec:
|
|
5
|
-
* - `event`: optional event name (default "message"
|
|
6
|
+
* - `event`: optional event name (absent for the default "message")
|
|
6
7
|
* - `data`: payload, with multiple `data:` lines joined by `\n`
|
|
7
8
|
* - `id`: optional last-event id
|
|
8
9
|
*/
|
|
@@ -12,98 +13,44 @@ export type Event = {
|
|
|
12
13
|
readonly id?: string
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
const toEvent = (wire: Sse.Event): Event => ({
|
|
17
|
+
data: wire.data,
|
|
18
|
+
...(wire.event !== "message" && { event: wire.event }),
|
|
19
|
+
...(wire.id !== undefined && { id: wire.id }),
|
|
20
|
+
})
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
Stream.mapAccum(
|
|
24
|
-
(): TextDecoder => new TextDecoder("utf-8"),
|
|
25
|
-
(decoder, chunk: Uint8Array) => [decoder, [decoder.decode(chunk, { stream: true })]] as const,
|
|
26
|
-
{
|
|
27
|
-
onHalt: (decoder: TextDecoder) => {
|
|
28
|
-
const tail = decoder.decode()
|
|
29
|
-
return tail.length > 0 ? [tail] : []
|
|
30
|
-
},
|
|
31
|
-
},
|
|
32
|
-
),
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
/** Split a text stream on a separator, buffering across chunk boundaries. */
|
|
36
|
-
const splitOn =
|
|
37
|
-
(separator: string) =>
|
|
38
|
-
<E, R>(self: Stream.Stream<string, E, R>): Stream.Stream<string, E, R> =>
|
|
39
|
-
self.pipe(
|
|
40
|
-
Stream.mapAccum(
|
|
41
|
-
(): string => "",
|
|
42
|
-
(buffer, chunk: string) => {
|
|
43
|
-
const parts = (buffer + chunk).split(separator)
|
|
44
|
-
const tail = parts[parts.length - 1] ?? ""
|
|
45
|
-
return [tail, parts.slice(0, -1)] as const
|
|
46
|
-
},
|
|
47
|
-
{ onHalt: (tail: string) => (tail.length > 0 ? [tail] : []) },
|
|
48
|
-
),
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
|
-
// Parser
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
|
|
55
|
-
const parseField = (line: string): readonly [string, string] => {
|
|
56
|
-
const colon = line.indexOf(":")
|
|
57
|
-
if (colon < 0) return [line, ""]
|
|
58
|
-
const value = line.slice(colon + 1)
|
|
59
|
-
return [line.slice(0, colon), value.startsWith(" ") ? value.slice(1) : value]
|
|
22
|
+
type ParserState = {
|
|
23
|
+
readonly parser: Sse.Parser
|
|
24
|
+
readonly out: Array<Event>
|
|
60
25
|
}
|
|
61
26
|
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const id = fields.find(([f]) => f === "id")?.[1]
|
|
70
|
-
|
|
71
|
-
const out: { event?: string; data: string; id?: string } = {
|
|
72
|
-
data: dataLines.join("\n"),
|
|
73
|
-
}
|
|
74
|
-
if (event !== undefined) out.event = event
|
|
75
|
-
if (id !== undefined) out.id = id
|
|
76
|
-
return out as Event
|
|
27
|
+
const makeParserState = (): ParserState => {
|
|
28
|
+
const out: Array<Event> = []
|
|
29
|
+
const parser = Sse.makeParser((event) => {
|
|
30
|
+
// `retry:` reconnection hints have no representation in `Event`
|
|
31
|
+
if (event._tag === "Event") out.push(toEvent(event))
|
|
32
|
+
})
|
|
33
|
+
return { parser, out }
|
|
77
34
|
}
|
|
78
35
|
|
|
79
|
-
// ---------------------------------------------------------------------------
|
|
80
|
-
// Public API
|
|
81
|
-
// ---------------------------------------------------------------------------
|
|
82
|
-
|
|
83
36
|
/**
|
|
84
37
|
* Decode a `Stream<Uint8Array>` (e.g. an HTTP response body) into a
|
|
85
|
-
* `Stream<SSE.Event
|
|
86
|
-
*
|
|
38
|
+
* `Stream<SSE.Event>`, using the spec parser from `effect/unstable/encoding`.
|
|
39
|
+
* An event is dispatched only once its terminating blank line arrives, so a
|
|
40
|
+
* partial event at stream end (clean or failed) is discarded per the spec
|
|
41
|
+
* rather than surfaced as a truncated frame.
|
|
87
42
|
*/
|
|
88
43
|
export const fromBytes = <E, R>(
|
|
89
44
|
self: Stream.Stream<Uint8Array, E, R>,
|
|
90
45
|
): Stream.Stream<Event, E, R> =>
|
|
91
46
|
self.pipe(
|
|
92
|
-
decodeText,
|
|
93
|
-
Stream.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
47
|
+
Stream.decodeText,
|
|
48
|
+
Stream.mapAccum(makeParserState, (state, text: string) => {
|
|
49
|
+
state.parser.feed(text)
|
|
50
|
+
return [state, state.out.splice(0)] as const
|
|
51
|
+
}),
|
|
97
52
|
)
|
|
98
53
|
|
|
99
|
-
const eventToString = (ev: Event): string => {
|
|
100
|
-
const parts: string[] = []
|
|
101
|
-
if (ev.event !== undefined) parts.push(`event: ${ev.event}`)
|
|
102
|
-
if (ev.id !== undefined) parts.push(`id: ${ev.id}`)
|
|
103
|
-
for (const line of ev.data.split("\n")) parts.push(`data: ${line}`)
|
|
104
|
-
return parts.join("\n") + "\n\n"
|
|
105
|
-
}
|
|
106
|
-
|
|
107
54
|
const encoder = new TextEncoder()
|
|
108
55
|
|
|
109
56
|
/**
|
|
@@ -111,4 +58,13 @@ const encoder = new TextEncoder()
|
|
|
111
58
|
* HTTP response with `Content-Type: text/event-stream`.
|
|
112
59
|
*/
|
|
113
60
|
export const toBytes = <E, R>(self: Stream.Stream<Event, E, R>): Stream.Stream<Uint8Array, E, R> =>
|
|
114
|
-
Stream.map(self, (ev) =>
|
|
61
|
+
Stream.map(self, (ev) =>
|
|
62
|
+
encoder.encode(
|
|
63
|
+
Sse.encoder.write({
|
|
64
|
+
_tag: "Event",
|
|
65
|
+
event: ev.event ?? "message",
|
|
66
|
+
id: ev.id,
|
|
67
|
+
data: ev.data,
|
|
68
|
+
}),
|
|
69
|
+
),
|
|
70
|
+
)
|
package/src/tool/Tool.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
|
|
2
|
-
import { Effect } from "effect"
|
|
2
|
+
import { Context, Effect, Schema } from "effect"
|
|
3
3
|
import { describe, expect, expectTypeOf, it } from "vitest"
|
|
4
4
|
import type * as Items from "../domain/Items.js"
|
|
5
5
|
import * as Tool from "./Tool.js"
|
|
@@ -156,3 +156,153 @@ describe("Tool.decodeArgs", () => {
|
|
|
156
156
|
expect(exit._tag).toBe("Failure")
|
|
157
157
|
})
|
|
158
158
|
})
|
|
159
|
+
|
|
160
|
+
describe("Effect Schema inputSchema", () => {
|
|
161
|
+
const Recipient = Schema.Struct({ to: Schema.String })
|
|
162
|
+
|
|
163
|
+
const sendEmail = Tool.make({
|
|
164
|
+
name: "send_email",
|
|
165
|
+
description: "Send an email to a single recipient.",
|
|
166
|
+
inputSchema: Recipient,
|
|
167
|
+
run: ({ to }) => Effect.succeed(`queued: ${to}`),
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const callWith = (args: string): Items.ToolCall => ({
|
|
171
|
+
type: "function_call",
|
|
172
|
+
call_id: "call_1",
|
|
173
|
+
name: "send_email",
|
|
174
|
+
arguments: args,
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("runs with the input inferred from the schema's Type", async () => {
|
|
178
|
+
const result = await Effect.runPromise(sendEmail.run({ to: "x@y.z" }, () => Effect.void))
|
|
179
|
+
expect(result).toBe("queued: x@y.z")
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it("type: Input is the schema's Type", () => {
|
|
183
|
+
type InputOf<T> = T extends Tool.Tool<string, infer I, any, any, any> ? I : never
|
|
184
|
+
expectTypeOf<InputOf<typeof sendEmail>>().toEqualTypeOf<{ readonly to: string }>()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it("type: E and R are inferred from run through the Effect Schema overload", () => {
|
|
188
|
+
class Mailer extends Context.Service<
|
|
189
|
+
Mailer,
|
|
190
|
+
{ readonly send: (to: string) => Effect.Effect<string> }
|
|
191
|
+
>()("test/Mailer") {}
|
|
192
|
+
|
|
193
|
+
const tool = Tool.make({
|
|
194
|
+
name: "send_email",
|
|
195
|
+
description: "send",
|
|
196
|
+
inputSchema: Recipient,
|
|
197
|
+
run: ({ to }) =>
|
|
198
|
+
Effect.gen(function* () {
|
|
199
|
+
const mailer = yield* Mailer
|
|
200
|
+
if (to === "") return yield* Effect.fail("empty" as const)
|
|
201
|
+
return yield* mailer.send(to)
|
|
202
|
+
}),
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
expectTypeOf<Tool.ToolE<typeof tool>>().toEqualTypeOf<"empty">()
|
|
206
|
+
expectTypeOf<Tool.ToolR<typeof tool>>().toEqualTypeOf<Mailer>()
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it("decodes valid JSON arguments to the typed input", async () => {
|
|
210
|
+
const input = await Effect.runPromise(Tool.decodeArgs(sendEmail, callWith('{"to":"x@y.z"}')))
|
|
211
|
+
expect(input).toEqual({ to: "x@y.z" })
|
|
212
|
+
expectTypeOf(input).toEqualTypeOf<{ readonly to: string }>()
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it("fails with ToolValidationError when the arguments don't satisfy the schema", async () => {
|
|
216
|
+
const exit = await Effect.runPromiseExit(Tool.decodeArgs(sendEmail, callWith('{"to":42}')))
|
|
217
|
+
expect(exit._tag).toBe("Failure")
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it("renders a JSON Schema descriptor from the wrapped schema", () => {
|
|
221
|
+
const [descriptor] = Tool.toDescriptors([sendEmail])
|
|
222
|
+
expect(descriptor?.name).toBe("send_email")
|
|
223
|
+
expect(descriptor?.inputSchema).toMatchObject({
|
|
224
|
+
type: "object",
|
|
225
|
+
properties: { to: { type: "string" } },
|
|
226
|
+
required: ["to"],
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it("accepts an already-wrapped fromEffectSchema value unchanged", async () => {
|
|
231
|
+
const wrapped = Tool.make({
|
|
232
|
+
name: "send_email",
|
|
233
|
+
description: "Send an email to a single recipient.",
|
|
234
|
+
inputSchema: Tool.fromEffectSchema(Recipient),
|
|
235
|
+
run: ({ to }) => Effect.succeed(`queued: ${to}`),
|
|
236
|
+
})
|
|
237
|
+
expect(Tool.toDescriptors([wrapped])).toEqual(Tool.toDescriptors([sendEmail]))
|
|
238
|
+
const input = await Effect.runPromise(Tool.decodeArgs(wrapped, callWith('{"to":"x@y.z"}')))
|
|
239
|
+
expectTypeOf(input).toEqualTypeOf<{ readonly to: string }>()
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it("signal: decode-only kinds accept an Effect Schema directly", async () => {
|
|
243
|
+
const escalate = Tool.signal({
|
|
244
|
+
name: "escalate",
|
|
245
|
+
description: "Escalate to a human.",
|
|
246
|
+
inputSchema: Schema.Struct({ reason: Schema.String }),
|
|
247
|
+
})
|
|
248
|
+
const input = await Effect.runPromise(
|
|
249
|
+
Tool.decodeArgs(escalate, {
|
|
250
|
+
type: "function_call",
|
|
251
|
+
call_id: "call_2",
|
|
252
|
+
name: "escalate",
|
|
253
|
+
arguments: '{"reason":"stuck"}',
|
|
254
|
+
}),
|
|
255
|
+
)
|
|
256
|
+
expect(input).toEqual({ reason: "stuck" })
|
|
257
|
+
expectTypeOf(input).toEqualTypeOf<{ readonly reason: string }>()
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
describe("provider-hosted tools", () => {
|
|
262
|
+
const localTool = Tool.make({
|
|
263
|
+
name: "send_email",
|
|
264
|
+
description: "Send an email.",
|
|
265
|
+
inputSchema: Tool.fromEffectSchema(Schema.Struct({ to: Schema.String })),
|
|
266
|
+
run: ({ to }) => Effect.succeed(`sent: ${to}`),
|
|
267
|
+
})
|
|
268
|
+
const signalTool = Tool.signal({
|
|
269
|
+
name: "escalate",
|
|
270
|
+
description: "Escalate to a human.",
|
|
271
|
+
inputSchema: Schema.Struct({ reason: Schema.String }),
|
|
272
|
+
})
|
|
273
|
+
const hostedTool = Tool.provider({
|
|
274
|
+
name: "web_search",
|
|
275
|
+
description: "Search the web.",
|
|
276
|
+
inputSchema: Tool.noInput,
|
|
277
|
+
provider: "gemini",
|
|
278
|
+
config: { kind: "google_search" },
|
|
279
|
+
})
|
|
280
|
+
const toolkit = { send_email: localTool, escalate: signalTool, web_search: hostedTool }
|
|
281
|
+
|
|
282
|
+
it("descriptorsOf excludes provider-hosted tools", () => {
|
|
283
|
+
const descriptors = Tool.descriptorsOf(toolkit)
|
|
284
|
+
expect(descriptors.map((d) => d.name)).toEqual(["send_email", "escalate"])
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it("toDescriptors drops a ProviderTool from a mixed array", () => {
|
|
288
|
+
expect(Tool.toDescriptors([localTool, hostedTool]).map((d) => d.name)).toEqual(["send_email"])
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it("providerToolsOf returns only the provider-hosted tools", () => {
|
|
292
|
+
const provider = Tool.providerToolsOf(toolkit)
|
|
293
|
+
expect(provider).toHaveLength(1)
|
|
294
|
+
expect(provider[0]?.name).toBe("web_search")
|
|
295
|
+
expect(provider[0]?.provider).toBe("gemini")
|
|
296
|
+
expect(provider[0]?.config).toEqual({ kind: "google_search" })
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it("providerToolsOf on an absent toolkit is empty", () => {
|
|
300
|
+
expect(Tool.providerToolsOf(undefined)).toEqual([])
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it("isProviderTool narrows the tool kind", () => {
|
|
304
|
+
expect(Tool.isProviderTool(hostedTool)).toBe(true)
|
|
305
|
+
expect(Tool.isProviderTool(localTool)).toBe(false)
|
|
306
|
+
expect(Tool.isProviderTool(signalTool)).toBe(false)
|
|
307
|
+
})
|
|
308
|
+
})
|