@effect-uai/core 0.9.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.
Files changed (196) hide show
  1. package/dist/{AiError-CrdR1EQi.d.mts → AiError-Bf1pq4BB.d.mts} +8 -3
  2. package/dist/{AiError-CrdR1EQi.d.mts.map → AiError-Bf1pq4BB.d.mts.map} +1 -1
  3. package/dist/{Audio-DDSKCkSe.d.mts → Audio-CR7CSljJ.d.mts} +2 -2
  4. package/dist/{Audio-DDSKCkSe.d.mts.map → Audio-CR7CSljJ.d.mts.map} +1 -1
  5. package/dist/{Image-C_Zjpwhc.d.mts → Image-C4vuBmu0.d.mts} +12 -12
  6. package/dist/{Image-C_Zjpwhc.d.mts.map → Image-C4vuBmu0.d.mts.map} +1 -1
  7. package/dist/{Items-CgKD6Npw.d.mts → Items-BWNu6VGp.d.mts} +118 -92
  8. package/dist/Items-BWNu6VGp.d.mts.map +1 -0
  9. package/dist/{Loop-B5_cqumi.d.mts → Loop-D8nWpowc.d.mts} +2 -2
  10. package/dist/{Loop-B5_cqumi.d.mts.map → Loop-D8nWpowc.d.mts.map} +1 -1
  11. package/dist/{Media-DOLyaygI.d.mts → Media-D_CpcM1Z.d.mts} +1 -1
  12. package/dist/{Media-DOLyaygI.d.mts.map → Media-D_CpcM1Z.d.mts.map} +1 -1
  13. package/dist/{StructuredFormat-CMJFD3sn.d.mts → StructuredFormat-DHaUJBdd.d.mts} +1 -1
  14. package/dist/{StructuredFormat-CMJFD3sn.d.mts.map → StructuredFormat-DHaUJBdd.d.mts.map} +1 -1
  15. package/dist/{Tool-0XUIT2A2.d.mts → Tool-CXFHCQrT.d.mts} +101 -16
  16. package/dist/Tool-CXFHCQrT.d.mts.map +1 -0
  17. package/dist/{ToolEvent-DYrAUJGs.d.mts → ToolEvent-nVijCrvm.d.mts} +2 -2
  18. package/dist/ToolEvent-nVijCrvm.d.mts.map +1 -0
  19. package/dist/{ToolResult-BZd-cEWW.d.mts → ToolResult-CQqKkcDa.d.mts} +10 -4
  20. package/dist/ToolResult-CQqKkcDa.d.mts.map +1 -0
  21. package/dist/{Toolkit-BKC8b8Xo.d.mts → Toolkit-BYPwwu-j.d.mts} +65 -24
  22. package/dist/Toolkit-BYPwwu-j.d.mts.map +1 -0
  23. package/dist/browser/Browser.d.mts +190 -0
  24. package/dist/browser/Browser.d.mts.map +1 -0
  25. package/dist/browser/Browser.mjs +34 -0
  26. package/dist/browser/Browser.mjs.map +1 -0
  27. package/dist/browser/BrowserError.d.mts +141 -0
  28. package/dist/browser/BrowserError.d.mts.map +1 -0
  29. package/dist/browser/BrowserError.mjs +89 -0
  30. package/dist/browser/BrowserError.mjs.map +1 -0
  31. package/dist/browser/BrowserTool.d.mts +59 -0
  32. package/dist/browser/BrowserTool.d.mts.map +1 -0
  33. package/dist/browser/BrowserTool.mjs +86 -0
  34. package/dist/browser/BrowserTool.mjs.map +1 -0
  35. package/dist/{dist-DWgRbDN3.mjs → dist-C0m0QnUP.mjs} +71 -30
  36. package/dist/dist-C0m0QnUP.mjs.map +1 -0
  37. package/dist/{dist-B04dlFHg.mjs → dist-CcRV3-A5.mjs} +8 -8
  38. package/dist/{dist-B04dlFHg.mjs.map → dist-CcRV3-A5.mjs.map} +1 -1
  39. package/dist/domain/AiError.d.mts +2 -2
  40. package/dist/domain/AiError.mjs +22 -3
  41. package/dist/domain/AiError.mjs.map +1 -1
  42. package/dist/domain/Audio.d.mts +1 -1
  43. package/dist/domain/Citation.d.mts +60 -0
  44. package/dist/domain/Citation.d.mts.map +1 -0
  45. package/dist/domain/Citation.mjs +7 -0
  46. package/dist/domain/Citation.mjs.map +1 -0
  47. package/dist/domain/Image.d.mts +1 -1
  48. package/dist/domain/Items.d.mts +1 -1
  49. package/dist/domain/Items.mjs +13 -3
  50. package/dist/domain/Items.mjs.map +1 -1
  51. package/dist/domain/Media.d.mts +1 -1
  52. package/dist/domain/Music.d.mts +1 -1
  53. package/dist/domain/Research.d.mts +37 -0
  54. package/dist/domain/Research.d.mts.map +1 -0
  55. package/dist/domain/Research.mjs +7 -0
  56. package/dist/domain/Research.mjs.map +1 -0
  57. package/dist/domain/Turn.d.mts +92 -10
  58. package/dist/domain/Turn.d.mts.map +1 -1
  59. package/dist/domain/Turn.mjs +8 -1
  60. package/dist/domain/Turn.mjs.map +1 -1
  61. package/dist/domain/Turn.test.mjs +1 -1
  62. package/dist/embedding-model/Embedding.d.mts +1 -1
  63. package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
  64. package/dist/embedding-model/EmbeddingModel.mjs +2 -2
  65. package/dist/embedding-model/EmbeddingModel.mjs.map +1 -1
  66. package/dist/embedding-model/EmbeddingModel.test.mjs +1 -1
  67. package/dist/index.d.mts +22 -13
  68. package/dist/index.mjs +18 -9
  69. package/dist/job/Job.d.mts +80 -0
  70. package/dist/job/Job.d.mts.map +1 -0
  71. package/dist/job/Job.mjs +49 -0
  72. package/dist/job/Job.mjs.map +1 -0
  73. package/dist/language-model/LanguageModel.d.mts +4 -4
  74. package/dist/language-model/LanguageModel.mjs +2 -2
  75. package/dist/language-model/LanguageModel.mjs.map +1 -1
  76. package/dist/language-model/LanguageModel.test.mjs +1 -1
  77. package/dist/loop/Loop.d.mts +1 -1
  78. package/dist/loop/Loop.test.mjs +1 -1
  79. package/dist/music-generator/MusicGenerator.d.mts +2 -2
  80. package/dist/music-generator/MusicGenerator.mjs +4 -4
  81. package/dist/music-generator/MusicGenerator.mjs.map +1 -1
  82. package/dist/music-generator/MusicGenerator.test.mjs +1 -1
  83. package/dist/observability/Metrics.d.mts +1 -1
  84. package/dist/observability/Metrics.test.mjs +2 -2
  85. package/dist/observability/Telemetry.test.mjs +2 -2
  86. package/dist/research/DeepResearch.d.mts +71 -0
  87. package/dist/research/DeepResearch.d.mts.map +1 -0
  88. package/dist/research/DeepResearch.mjs +61 -0
  89. package/dist/research/DeepResearch.mjs.map +1 -0
  90. package/dist/retry/Retry.d.mts +1 -1
  91. package/dist/retry/Retry.test.mjs +1 -1
  92. package/dist/sandbox/Sandbox.mjs +18 -18
  93. package/dist/sandbox/Sandbox.mjs.map +1 -1
  94. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +2 -2
  95. package/dist/speech-synthesizer/SpeechSynthesizer.mjs +8 -8
  96. package/dist/speech-synthesizer/SpeechSynthesizer.mjs.map +1 -1
  97. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +1 -1
  98. package/dist/streaming/JSONL.d.mts +3 -1
  99. package/dist/streaming/JSONL.d.mts.map +1 -1
  100. package/dist/streaming/JSONL.mjs +5 -11
  101. package/dist/streaming/JSONL.mjs.map +1 -1
  102. package/dist/streaming/JSONL.test.mjs +10 -1
  103. package/dist/streaming/JSONL.test.mjs.map +1 -1
  104. package/dist/streaming/SSE.d.mts +5 -3
  105. package/dist/streaming/SSE.d.mts.map +1 -1
  106. package/dist/streaming/SSE.mjs +28 -38
  107. package/dist/streaming/SSE.mjs.map +1 -1
  108. package/dist/streaming/SSE.test.mjs +54 -4
  109. package/dist/streaming/SSE.test.mjs.map +1 -1
  110. package/dist/structured-format/StructuredFormat.d.mts +1 -1
  111. package/dist/structured-format/StructuredFormat.test.mjs +1 -1
  112. package/dist/testing/MockMusicGenerator.d.mts +1 -1
  113. package/dist/testing/MockProvider.d.mts +1 -1
  114. package/dist/testing/MockSpeechSynthesizer.d.mts +1 -1
  115. package/dist/tool/Approval.d.mts +19 -7
  116. package/dist/tool/Approval.d.mts.map +1 -1
  117. package/dist/tool/Approval.mjs +18 -11
  118. package/dist/tool/Approval.mjs.map +1 -1
  119. package/dist/tool/Approval.test.mjs +47 -8
  120. package/dist/tool/Approval.test.mjs.map +1 -1
  121. package/dist/tool/HistoryCheck.d.mts +2 -2
  122. package/dist/tool/Tool.d.mts +2 -2
  123. package/dist/tool/Tool.mjs +112 -28
  124. package/dist/tool/Tool.mjs.map +1 -1
  125. package/dist/tool/Tool.test.mjs +127 -3
  126. package/dist/tool/Tool.test.mjs.map +1 -1
  127. package/dist/tool/ToolEvent.d.mts +1 -1
  128. package/dist/tool/ToolEvent.mjs +5 -0
  129. package/dist/tool/ToolEvent.mjs.map +1 -1
  130. package/dist/tool/ToolResult.d.mts +2 -2
  131. package/dist/tool/ToolResult.mjs +15 -6
  132. package/dist/tool/ToolResult.mjs.map +1 -1
  133. package/dist/tool/Toolkit.d.mts +2 -2
  134. package/dist/tool/Toolkit.mjs +62 -22
  135. package/dist/tool/Toolkit.mjs.map +1 -1
  136. package/dist/tool/Toolkit.test.mjs +207 -9
  137. package/dist/tool/Toolkit.test.mjs.map +1 -1
  138. package/dist/transcriber/Transcriber.d.mts +2 -2
  139. package/dist/transcriber/Transcriber.mjs +3 -3
  140. package/dist/transcriber/Transcriber.mjs.map +1 -1
  141. package/dist/transcriber/Transcriber.test.mjs +1 -1
  142. package/dist/web-read/WebRead.d.mts +44 -0
  143. package/dist/web-read/WebRead.d.mts.map +1 -0
  144. package/dist/web-read/WebRead.mjs +18 -0
  145. package/dist/web-read/WebRead.mjs.map +1 -0
  146. package/dist/web-read/WebReadTool.d.mts +57 -0
  147. package/dist/web-read/WebReadTool.d.mts.map +1 -0
  148. package/dist/web-read/WebReadTool.mjs +59 -0
  149. package/dist/web-read/WebReadTool.mjs.map +1 -0
  150. package/dist/web-search/WebSearch.d.mts +1 -1
  151. package/dist/web-search/WebSearch.mjs +1 -1
  152. package/dist/web-search/WebSearch.mjs.map +1 -1
  153. package/dist/web-search/WebSearchTool.d.mts +12 -6
  154. package/dist/web-search/WebSearchTool.d.mts.map +1 -1
  155. package/dist/web-search/WebSearchTool.mjs +10 -5
  156. package/dist/web-search/WebSearchTool.mjs.map +1 -1
  157. package/package.json +39 -3
  158. package/src/browser/Browser.ts +268 -0
  159. package/src/browser/BrowserError.ts +145 -0
  160. package/src/browser/BrowserTool.ts +161 -0
  161. package/src/domain/AiError.ts +27 -1
  162. package/src/domain/Citation.ts +64 -0
  163. package/src/domain/Items.ts +14 -4
  164. package/src/domain/Research.ts +42 -0
  165. package/src/domain/Turn.ts +26 -0
  166. package/src/embedding-model/EmbeddingModel.ts +2 -2
  167. package/src/index.ts +9 -0
  168. package/src/job/Job.ts +125 -0
  169. package/src/language-model/LanguageModel.ts +2 -2
  170. package/src/music-generator/MusicGenerator.ts +4 -4
  171. package/src/research/DeepResearch.ts +137 -0
  172. package/src/sandbox/Sandbox.ts +18 -18
  173. package/src/speech-synthesizer/SpeechSynthesizer.ts +8 -8
  174. package/src/streaming/JSONL.test.ts +19 -0
  175. package/src/streaming/JSONL.ts +6 -38
  176. package/src/streaming/SSE.test.ts +86 -3
  177. package/src/streaming/SSE.ts +36 -80
  178. package/src/tool/Approval.test.ts +55 -5
  179. package/src/tool/Approval.ts +72 -27
  180. package/src/tool/Tool.test.ts +151 -1
  181. package/src/tool/Tool.ts +263 -66
  182. package/src/tool/ToolEvent.ts +5 -0
  183. package/src/tool/ToolResult.ts +14 -8
  184. package/src/tool/Toolkit.test.ts +231 -2
  185. package/src/tool/Toolkit.ts +175 -62
  186. package/src/transcriber/Transcriber.ts +3 -3
  187. package/src/web-read/WebRead.ts +70 -0
  188. package/src/web-read/WebReadTool.ts +96 -0
  189. package/src/web-search/WebSearch.ts +1 -1
  190. package/src/web-search/WebSearchTool.ts +11 -5
  191. package/dist/Items-CgKD6Npw.d.mts.map +0 -1
  192. package/dist/Tool-0XUIT2A2.d.mts.map +0 -1
  193. package/dist/ToolEvent-DYrAUJGs.d.mts.map +0 -1
  194. package/dist/ToolResult-BZd-cEWW.d.mts.map +0 -1
  195. package/dist/Toolkit-BKC8b8Xo.d.mts.map +0 -1
  196. package/dist/dist-DWgRbDN3.mjs.map +0 -1
package/src/job/Job.ts ADDED
@@ -0,0 +1,125 @@
1
+ import { Duration, Effect, Schedule } from "effect"
2
+ import * as AiError from "../domain/AiError.js"
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Status + handle
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /**
9
+ * Normalized state of a provider-hosted background job. Implementors map their
10
+ * provider's status vocabulary onto these four states. The terminal states
11
+ * carry their payload: `Succeeded` the collected result, `Failed` an optional
12
+ * reason and the raw provider error. This mirrors the wire, where a single
13
+ * poll response returns both status and (when done) the result.
14
+ */
15
+ export type JobState<A> =
16
+ | { readonly _tag: "Pending" }
17
+ | { readonly _tag: "Running" }
18
+ | { readonly _tag: "Succeeded"; readonly result: A }
19
+ | { readonly _tag: "Failed"; readonly reason?: string; readonly raw?: unknown }
20
+
21
+ /** The two states that end a poll loop. */
22
+ export const isSettled = <A>(
23
+ state: JobState<A>,
24
+ ): state is Extract<JobState<A>, { _tag: "Succeeded" | "Failed" }> =>
25
+ state._tag === "Succeeded" || state._tag === "Failed"
26
+
27
+ declare const ResultType: unique symbol
28
+
29
+ /**
30
+ * Opaque, provider-tagged handle to a running background job. Jobs can run for
31
+ * minutes and outlive a single process, so the handle is detachable: submit
32
+ * now, collect later. `provider` tags who owns `id`.
33
+ *
34
+ * Parameterized by the result type `A` it collects to, so a ref cannot be
35
+ * crossed between capabilities (a `JobRef<Turn>` is not a `JobRef<VideoResult>`).
36
+ * The brand is phantom: the runtime value is just `{ _tag, provider, id }`, so
37
+ * a ref stays plain serializable data for persistence across restarts.
38
+ */
39
+ export type JobRef<A = unknown> = {
40
+ readonly _tag: "JobRef"
41
+ readonly provider: string
42
+ readonly id: string
43
+ readonly [ResultType]?: A
44
+ }
45
+
46
+ export const jobRef = <A = unknown>(provider: string, id: string): JobRef<A> => ({
47
+ _tag: "JobRef",
48
+ provider,
49
+ id,
50
+ })
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Poll driver
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * The three wire operations a poll-based async job supplies. `run` / `collect`
58
+ * drive the submit -> poll -> settle cadence around them, so an implementor
59
+ * states only its provider calls, not the loop. `poll` returns the current
60
+ * `JobState`, carrying the result in `Succeeded`. Generic over the collected
61
+ * `Result`.
62
+ */
63
+ export type JobOps<Result> = {
64
+ readonly submit: Effect.Effect<JobRef<Result>, AiError.AiError>
65
+ readonly poll: (ref: JobRef<Result>) => Effect.Effect<JobState<Result>, AiError.AiError>
66
+ readonly cancel: (ref: JobRef<Result>) => Effect.Effect<void, AiError.AiError>
67
+ }
68
+
69
+ export type JobConfig = {
70
+ /** Cadence between status fetches. Default 10 seconds, jittered. */
71
+ readonly pollInterval?: Duration.Input
72
+ /** Overall wait cap; exceeding it fails `Timeout`. Default 45 minutes. */
73
+ readonly timeout?: Duration.Input
74
+ }
75
+
76
+ const DEFAULT_POLL_INTERVAL: Duration.Input = "10 seconds"
77
+ const DEFAULT_TIMEOUT: Duration.Input = "45 minutes"
78
+
79
+ /**
80
+ * Poll `poll(ref)` on a jittered fixed cadence until the job settles, then
81
+ * return the result or fail `GenerationFailed`. Bounded by `timeout`, which
82
+ * fails `Timeout` if the job never settles. Takes the poll op alone (not the
83
+ * full `JobOps`) so a detached `collect(ref)` needs no submit-bound request.
84
+ */
85
+ export const collect = <Result>(
86
+ poll: (ref: JobRef<Result>) => Effect.Effect<JobState<Result>, AiError.AiError>,
87
+ ref: JobRef<Result>,
88
+ config?: JobConfig,
89
+ ): Effect.Effect<Result, AiError.AiError> =>
90
+ poll(ref).pipe(
91
+ Effect.repeat({
92
+ schedule: Schedule.jittered(Schedule.spaced(config?.pollInterval ?? DEFAULT_POLL_INTERVAL)),
93
+ until: isSettled,
94
+ }),
95
+ Effect.flatMap((state) =>
96
+ state._tag === "Succeeded"
97
+ ? Effect.succeed(state.result)
98
+ : Effect.fail(
99
+ new AiError.GenerationFailed({
100
+ provider: ref.provider,
101
+ message: state._tag === "Failed" ? (state.reason ?? "job failed") : "job failed",
102
+ raw: state._tag === "Failed" ? (state.raw ?? ref) : ref,
103
+ }),
104
+ ),
105
+ ),
106
+ Effect.timeoutOrElse({
107
+ duration: config?.timeout ?? DEFAULT_TIMEOUT,
108
+ orElse: () => Effect.fail(new AiError.Timeout({ provider: ref.provider, raw: ref })),
109
+ }),
110
+ )
111
+
112
+ /**
113
+ * Submit, then poll to completion. Best-effort cancels the server job if the
114
+ * caller interrupts (where the provider supports it). The one-call path; use
115
+ * `ops.submit` + `collect` directly for the detached case.
116
+ */
117
+ export const run = <Result>(
118
+ ops: JobOps<Result>,
119
+ config?: JobConfig,
120
+ ): Effect.Effect<Result, AiError.AiError> =>
121
+ ops.submit.pipe(
122
+ Effect.flatMap((ref) =>
123
+ collect(ops.poll, ref, config).pipe(Effect.onInterrupt(() => Effect.ignore(ops.cancel(ref)))),
124
+ ),
125
+ )
@@ -65,7 +65,7 @@ export class LanguageModel extends Context.Service<LanguageModel, LanguageModelS
65
65
  export const streamTurn = (
66
66
  request: CommonRequest,
67
67
  ): Stream.Stream<TurnEvent, AiError.AiError, LanguageModel> =>
68
- Stream.unwrap(Effect.map(LanguageModel.asEffect(), (m) => m.streamTurn(request)))
68
+ Stream.unwrap(Effect.map(LanguageModel, (m) => m.streamTurn(request)))
69
69
 
70
70
  /**
71
71
  * Drain a single turn and return the assembled `Turn`. Delegates to the
@@ -74,7 +74,7 @@ export const streamTurn = (
74
74
  * {@link turnFromStream}.
75
75
  */
76
76
  export const turn = (request: CommonRequest): Effect.Effect<Turn, AiError.AiError, LanguageModel> =>
77
- Effect.flatMap(LanguageModel.asEffect(), (m) => m.turn(request))
77
+ Effect.flatMap(LanguageModel, (m) => m.turn(request))
78
78
 
79
79
  /**
80
80
  * Build a `turn` implementation from a `streamTurn` implementation.
@@ -94,13 +94,13 @@ export class MusicInteractiveSession extends Context.Service<MusicInteractiveSes
94
94
  export const generate = (
95
95
  request: CommonGenerateMusicRequest,
96
96
  ): Effect.Effect<GenerateResult, AiError.AiError, MusicGenerator> =>
97
- Effect.flatMap(MusicGenerator.asEffect(), (s) => s.generate(request))
97
+ Effect.flatMap(MusicGenerator, (s) => s.generate(request))
98
98
 
99
99
  /** Prompt in, audio chunks out. */
100
100
  export const streamGeneration = (
101
101
  request: CommonStreamGenerateMusicRequest,
102
102
  ): Stream.Stream<AudioChunk, AiError.AiError, MusicGenerator> =>
103
- Stream.unwrap(Effect.map(MusicGenerator.asEffect(), (s) => s.streamGeneration(request)))
103
+ Stream.unwrap(Effect.map(MusicGenerator, (s) => s.streamGeneration(request)))
104
104
 
105
105
  /**
106
106
  * Bidirectional generation. Dual-arity: pipeable (data-last) and
@@ -156,8 +156,8 @@ export const streamGenerationFrom: {
156
156
  > =>
157
157
  Stream.unwrap(
158
158
  Effect.gen(function* () {
159
- const s = yield* MusicGenerator.asEffect()
160
- yield* MusicInteractiveSession.asEffect()
159
+ const s = yield* MusicGenerator
160
+ yield* MusicInteractiveSession
161
161
  return s.streamGenerationFrom(input, request)
162
162
  }),
163
163
  ),
@@ -0,0 +1,137 @@
1
+ import { Context, Effect, Stream } from "effect"
2
+ import * as AiError from "../domain/AiError.js"
3
+ import * as Job from "../job/Job.js"
4
+ import type { ResearchJobRef, ResearchRequest, ResearchState } from "../domain/Research.js"
5
+ import type { Turn } from "../domain/Turn.js"
6
+ import { TurnEvent } from "../domain/Turn.js"
7
+
8
+ export type { ResearchJobRef, ResearchRequest, ResearchState } from "../domain/Research.js"
9
+
10
+ /**
11
+ * Deep research: submit a question, watch a long-running background job, collect
12
+ * one cited report. Every provider is modeled as a job (`submit` / `poll` /
13
+ * `cancel`), so the surface is uniform. The convenience derivations
14
+ * (`research` / `researchStream` / `collect` / `status`) are the same for every
15
+ * provider and are built once by {@link fromJob}, so an implementor states only
16
+ * its wire calls, never the poll loop.
17
+ *
18
+ * `research` is the blocking one-liner (submit then poll to the terminal `Turn`);
19
+ * `researchStream` forwards live progress as `TurnEvent`s, terminating in
20
+ * `TurnComplete` whose `turn` is the value `research` / `collect` return. The job
21
+ * ref is plain serializable data, so it can be persisted and collected from a
22
+ * later process where the provider's job is server-backed.
23
+ */
24
+ export type DeepResearchServiceShape<Req> = {
25
+ readonly submit: (request: Req) => Effect.Effect<ResearchJobRef, AiError.AiError>
26
+ readonly status: (ref: ResearchJobRef) => Effect.Effect<ResearchState, AiError.AiError>
27
+ readonly collect: (ref: ResearchJobRef) => Effect.Effect<Turn, AiError.AiError>
28
+ readonly streamFrom: (ref: ResearchJobRef) => Stream.Stream<TurnEvent, AiError.AiError>
29
+ readonly cancel: (ref: ResearchJobRef) => Effect.Effect<void, AiError.AiError>
30
+ readonly research: (request: Req) => Effect.Effect<Turn, AiError.AiError>
31
+ readonly researchStream: (request: Req) => Stream.Stream<TurnEvent, AiError.AiError>
32
+ }
33
+
34
+ export type DeepResearchService = DeepResearchServiceShape<ResearchRequest>
35
+
36
+ export class DeepResearch extends Context.Service<DeepResearch, DeepResearchService>()(
37
+ "@betalyra/effect-uai/DeepResearch",
38
+ ) {}
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // fromJob: build the whole service from the three job ops (+ an optional real
42
+ // live stream). The derivations are identical for every provider.
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /** The wire ops a provider supplies. `streamFrom` is optional: poll-only jobs
46
+ * get a synthesized stream ({@link synthesizedStream}) in its place. */
47
+ export type ResearchJobOps<Req> = {
48
+ readonly submit: (request: Req) => Effect.Effect<ResearchJobRef, AiError.AiError>
49
+ readonly poll: (ref: ResearchJobRef) => Effect.Effect<ResearchState, AiError.AiError>
50
+ readonly cancel: (ref: ResearchJobRef) => Effect.Effect<void, AiError.AiError>
51
+ readonly streamFrom?: (ref: ResearchJobRef) => Stream.Stream<TurnEvent, AiError.AiError>
52
+ }
53
+
54
+ // A poll-only job has no event stream, so synthesize one: a leading "searching"
55
+ // progress event, then the terminal `TurnComplete` off the poll loop. Honest
56
+ // low-fidelity progress, not a fabricated per-search feed.
57
+ const synthesizedStream = <Req>(
58
+ ops: ResearchJobOps<Req>,
59
+ ref: ResearchJobRef,
60
+ config?: Job.JobConfig,
61
+ ): Stream.Stream<TurnEvent, AiError.AiError> =>
62
+ Stream.concat(
63
+ Stream.make(TurnEvent.WebSearchCall({ status: "searching" })),
64
+ Stream.map(Stream.fromEffect(Job.collect(ops.poll, ref, config)), (turn) =>
65
+ TurnEvent.TurnComplete({ turn }),
66
+ ),
67
+ )
68
+
69
+ /**
70
+ * Assemble a {@link DeepResearchServiceShape} from the job ops. `research` is
71
+ * `submit` then poll-to-settle (best-effort cancel on interrupt, via
72
+ * {@link Job.run}); `collect` / `status` are the detached equivalents;
73
+ * `researchStream` submits then attaches. Generic over the request type so a
74
+ * provider-typed tag keeps its narrowed `submit`. `config` tunes the poll
75
+ * cadence and overall timeout of every derived poll loop (see
76
+ * {@link Job.JobConfig} for the defaults).
77
+ */
78
+ export const fromJob = <Req extends ResearchRequest>(
79
+ ops: ResearchJobOps<Req>,
80
+ config?: Job.JobConfig,
81
+ ): DeepResearchServiceShape<Req> => {
82
+ const streamFrom =
83
+ ops.streamFrom ?? ((ref: ResearchJobRef) => synthesizedStream(ops, ref, config))
84
+ return {
85
+ submit: ops.submit,
86
+ status: ops.poll,
87
+ collect: (ref) => Job.collect(ops.poll, ref, config),
88
+ cancel: ops.cancel,
89
+ streamFrom,
90
+ research: (request) =>
91
+ Job.run({ submit: ops.submit(request), poll: ops.poll, cancel: ops.cancel }, config),
92
+ researchStream: (request) => Stream.unwrap(Effect.map(ops.submit(request), streamFrom)),
93
+ }
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Top-level accessors over the generic tag. Portable code uses these; they
98
+ // require only `DeepResearch` in `R`.
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /** Submit and poll to the terminal `Turn`. Cancels the job on interrupt. */
102
+ export const research = (
103
+ request: ResearchRequest,
104
+ ): Effect.Effect<Turn, AiError.AiError, DeepResearch> =>
105
+ Effect.flatMap(DeepResearch, (s) => s.research(request))
106
+
107
+ /** Submit and forward live progress, terminating in `TurnComplete`. */
108
+ export const researchStream = (
109
+ request: ResearchRequest,
110
+ ): Stream.Stream<TurnEvent, AiError.AiError, DeepResearch> =>
111
+ Stream.unwrap(Effect.map(DeepResearch, (s) => s.researchStream(request)))
112
+
113
+ /** Submit a detached job and return its ref. */
114
+ export const submit = (
115
+ request: ResearchRequest,
116
+ ): Effect.Effect<ResearchJobRef, AiError.AiError, DeepResearch> =>
117
+ Effect.flatMap(DeepResearch, (s) => s.submit(request))
118
+
119
+ /** Current state of a job. */
120
+ export const status = (
121
+ ref: ResearchJobRef,
122
+ ): Effect.Effect<ResearchState, AiError.AiError, DeepResearch> =>
123
+ Effect.flatMap(DeepResearch, (s) => s.status(ref))
124
+
125
+ /** Poll a detached job to completion. */
126
+ export const collect = (ref: ResearchJobRef): Effect.Effect<Turn, AiError.AiError, DeepResearch> =>
127
+ Effect.flatMap(DeepResearch, (s) => s.collect(ref))
128
+
129
+ /** Attach a live progress stream to a job (call again to re-attach). */
130
+ export const streamFrom = (
131
+ ref: ResearchJobRef,
132
+ ): Stream.Stream<TurnEvent, AiError.AiError, DeepResearch> =>
133
+ Stream.unwrap(Effect.map(DeepResearch, (s) => s.streamFrom(ref)))
134
+
135
+ /** Cancel a running job. */
136
+ export const cancel = (ref: ResearchJobRef): Effect.Effect<void, AiError.AiError, DeepResearch> =>
137
+ Effect.flatMap(DeepResearch, (s) => s.cancel(ref))
@@ -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.asEffect(), (s) => s.create(request))
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.asEffect(), (s) => s.attach(id))
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.asEffect(), (s) => s.list)
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.asEffect(), (s) => s.destroy(id))
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.asEffect()
561
- yield* SandboxSnapshots.asEffect()
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.asEffect()
574
- yield* SandboxVolumes.asEffect()
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.asEffect()
584
- yield* SandboxVolumes.asEffect()
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.asEffect()
595
- yield* SandboxVolumes.asEffect()
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.asEffect()
605
- yield* SandboxSnapshots.asEffect()
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.asEffect()
616
- yield* SandboxSnapshots.asEffect()
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.asEffect()
637
- yield* SandboxPortExposure.asEffect()
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.asEffect(), (s) => s.synthesize(request))
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.asEffect(), (s) => s.streamSynthesis(request)))
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.asEffect()
209
- yield* TtsIncrementalText.asEffect()
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.asEffect()
224
- yield* MultiSpeakerTts.asEffect()
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.asEffect()
237
- yield* MultiSpeakerTts.asEffect()
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", () => {
@@ -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.map((s) => s.replace(/\r/g, "")),
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("flushes a trailing event without a closing blank line", async () => {
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([{ data: "tail" }])
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 () => {