@effect-uai/core 0.3.0 → 0.5.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 (169) hide show
  1. package/dist/{AiError-CBuPHVKA.d.mts → AiError-CAX_48RU.d.mts} +27 -5
  2. package/dist/{AiError-CBuPHVKA.d.mts.map → AiError-CAX_48RU.d.mts.map} +1 -1
  3. package/dist/Audio-BfCTGnH3.d.mts +61 -0
  4. package/dist/Audio-BfCTGnH3.d.mts.map +1 -0
  5. package/dist/{Image-BZmKfIdq.d.mts → Image-HNmMpMTh.d.mts} +1 -1
  6. package/dist/{Image-BZmKfIdq.d.mts.map → Image-HNmMpMTh.d.mts.map} +1 -1
  7. package/dist/{Items-CB8Bo3FI.d.mts → Items-DqbaJoz7.d.mts} +5 -5
  8. package/dist/{Items-CB8Bo3FI.d.mts.map → Items-DqbaJoz7.d.mts.map} +1 -1
  9. package/dist/{StructuredFormat-BWq5Hd1O.d.mts → StructuredFormat-BbN4dosH.d.mts} +11 -4
  10. package/dist/StructuredFormat-BbN4dosH.d.mts.map +1 -0
  11. package/dist/{Tool-DjVufH7i.d.mts → Tool-Y0__Py1H.d.mts} +20 -4
  12. package/dist/Tool-Y0__Py1H.d.mts.map +1 -0
  13. package/dist/Turn-ChbL2foc.d.mts +388 -0
  14. package/dist/Turn-ChbL2foc.d.mts.map +1 -0
  15. package/dist/domain/AiError.d.mts +2 -2
  16. package/dist/domain/AiError.mjs +19 -3
  17. package/dist/domain/AiError.mjs.map +1 -1
  18. package/dist/domain/Audio.d.mts +2 -0
  19. package/dist/domain/Audio.mjs +14 -0
  20. package/dist/domain/Audio.mjs.map +1 -0
  21. package/dist/domain/Image.d.mts +1 -1
  22. package/dist/domain/Items.d.mts +1 -1
  23. package/dist/domain/Items.mjs +1 -1
  24. package/dist/domain/Items.mjs.map +1 -1
  25. package/dist/domain/Music.d.mts +116 -0
  26. package/dist/domain/Music.d.mts.map +1 -0
  27. package/dist/domain/Music.mjs +29 -0
  28. package/dist/domain/Music.mjs.map +1 -0
  29. package/dist/domain/Transcript.d.mts +95 -0
  30. package/dist/domain/Transcript.d.mts.map +1 -0
  31. package/dist/domain/Transcript.mjs +22 -0
  32. package/dist/domain/Transcript.mjs.map +1 -0
  33. package/dist/domain/Turn.d.mts +2 -2
  34. package/dist/domain/Turn.mjs +22 -4
  35. package/dist/domain/Turn.mjs.map +1 -1
  36. package/dist/domain/Turn.test.d.mts +1 -0
  37. package/dist/domain/Turn.test.mjs +136 -0
  38. package/dist/domain/Turn.test.mjs.map +1 -0
  39. package/dist/embedding-model/Embedding.d.mts +15 -3
  40. package/dist/embedding-model/Embedding.d.mts.map +1 -1
  41. package/dist/embedding-model/Embedding.mjs.map +1 -1
  42. package/dist/embedding-model/EmbeddingModel.d.mts +33 -17
  43. package/dist/embedding-model/EmbeddingModel.d.mts.map +1 -1
  44. package/dist/embedding-model/EmbeddingModel.mjs.map +1 -1
  45. package/dist/embedding-model/EmbeddingModel.test.d.mts +1 -0
  46. package/dist/embedding-model/EmbeddingModel.test.mjs +59 -0
  47. package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -0
  48. package/dist/index.d.mts +13 -7
  49. package/dist/index.mjs +7 -1
  50. package/dist/language-model/LanguageModel.d.mts +30 -8
  51. package/dist/language-model/LanguageModel.d.mts.map +1 -1
  52. package/dist/language-model/LanguageModel.mjs +33 -3
  53. package/dist/language-model/LanguageModel.mjs.map +1 -1
  54. package/dist/language-model/LanguageModel.test.d.mts +1 -0
  55. package/dist/language-model/LanguageModel.test.mjs +143 -0
  56. package/dist/language-model/LanguageModel.test.mjs.map +1 -0
  57. package/dist/loop/Loop.d.mts +94 -11
  58. package/dist/loop/Loop.d.mts.map +1 -1
  59. package/dist/loop/Loop.mjs +92 -26
  60. package/dist/loop/Loop.mjs.map +1 -1
  61. package/dist/loop/Loop.test.mjs +171 -3
  62. package/dist/loop/Loop.test.mjs.map +1 -1
  63. package/dist/music-generator/MusicGenerator.d.mts +77 -0
  64. package/dist/music-generator/MusicGenerator.d.mts.map +1 -0
  65. package/dist/music-generator/MusicGenerator.mjs +51 -0
  66. package/dist/music-generator/MusicGenerator.mjs.map +1 -0
  67. package/dist/music-generator/MusicGenerator.test.d.mts +1 -0
  68. package/dist/music-generator/MusicGenerator.test.mjs +154 -0
  69. package/dist/music-generator/MusicGenerator.test.mjs.map +1 -0
  70. package/dist/observability/Metrics.d.mts +1 -1
  71. package/dist/observability/Metrics.mjs +1 -1
  72. package/dist/observability/Metrics.mjs.map +1 -1
  73. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +96 -0
  74. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts.map +1 -0
  75. package/dist/speech-synthesizer/SpeechSynthesizer.mjs +48 -0
  76. package/dist/speech-synthesizer/SpeechSynthesizer.mjs.map +1 -0
  77. package/dist/speech-synthesizer/SpeechSynthesizer.test.d.mts +1 -0
  78. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +112 -0
  79. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs.map +1 -0
  80. package/dist/streaming/JSONL.d.mts +10 -3
  81. package/dist/streaming/JSONL.d.mts.map +1 -1
  82. package/dist/streaming/JSONL.mjs +15 -9
  83. package/dist/streaming/JSONL.mjs.map +1 -1
  84. package/dist/structured-format/StructuredFormat.d.mts +2 -2
  85. package/dist/structured-format/StructuredFormat.mjs +9 -1
  86. package/dist/structured-format/StructuredFormat.mjs.map +1 -1
  87. package/dist/structured-format/StructuredFormat.test.d.mts +1 -0
  88. package/dist/structured-format/StructuredFormat.test.mjs +70 -0
  89. package/dist/structured-format/StructuredFormat.test.mjs.map +1 -0
  90. package/dist/testing/MockMusicGenerator.d.mts +39 -0
  91. package/dist/testing/MockMusicGenerator.d.mts.map +1 -0
  92. package/dist/testing/MockMusicGenerator.mjs +96 -0
  93. package/dist/testing/MockMusicGenerator.mjs.map +1 -0
  94. package/dist/testing/MockProvider.d.mts +23 -18
  95. package/dist/testing/MockProvider.d.mts.map +1 -1
  96. package/dist/testing/MockProvider.mjs +56 -72
  97. package/dist/testing/MockProvider.mjs.map +1 -1
  98. package/dist/testing/MockSpeechSynthesizer.d.mts +37 -0
  99. package/dist/testing/MockSpeechSynthesizer.d.mts.map +1 -0
  100. package/dist/testing/MockSpeechSynthesizer.mjs +95 -0
  101. package/dist/testing/MockSpeechSynthesizer.mjs.map +1 -0
  102. package/dist/testing/MockTranscriber.d.mts +37 -0
  103. package/dist/testing/MockTranscriber.d.mts.map +1 -0
  104. package/dist/testing/MockTranscriber.mjs +77 -0
  105. package/dist/testing/MockTranscriber.mjs.map +1 -0
  106. package/dist/tool/HistoryCheck.d.mts +1 -1
  107. package/dist/tool/Outcome.d.mts +1 -1
  108. package/dist/tool/Resolvers.d.mts +65 -8
  109. package/dist/tool/Resolvers.d.mts.map +1 -1
  110. package/dist/tool/Resolvers.mjs +8 -12
  111. package/dist/tool/Resolvers.mjs.map +1 -1
  112. package/dist/tool/Resolvers.test.mjs +6 -5
  113. package/dist/tool/Resolvers.test.mjs.map +1 -1
  114. package/dist/tool/Tool.d.mts +2 -2
  115. package/dist/tool/Tool.mjs +18 -1
  116. package/dist/tool/Tool.mjs.map +1 -1
  117. package/dist/tool/Tool.test.d.mts +1 -0
  118. package/dist/tool/Tool.test.mjs +66 -0
  119. package/dist/tool/Tool.test.mjs.map +1 -0
  120. package/dist/tool/Toolkit.d.mts +4 -6
  121. package/dist/tool/Toolkit.d.mts.map +1 -1
  122. package/dist/tool/Toolkit.mjs +14 -43
  123. package/dist/tool/Toolkit.mjs.map +1 -1
  124. package/dist/transcriber/Transcriber.d.mts +101 -0
  125. package/dist/transcriber/Transcriber.d.mts.map +1 -0
  126. package/dist/transcriber/Transcriber.mjs +49 -0
  127. package/dist/transcriber/Transcriber.mjs.map +1 -0
  128. package/dist/transcriber/Transcriber.test.d.mts +1 -0
  129. package/dist/transcriber/Transcriber.test.mjs +130 -0
  130. package/dist/transcriber/Transcriber.test.mjs.map +1 -0
  131. package/package.json +37 -1
  132. package/src/domain/AiError.ts +22 -1
  133. package/src/domain/Audio.ts +88 -0
  134. package/src/domain/Items.ts +1 -1
  135. package/src/domain/Music.ts +121 -0
  136. package/src/domain/Transcript.ts +83 -0
  137. package/src/domain/Turn.test.ts +141 -0
  138. package/src/domain/Turn.ts +50 -43
  139. package/src/embedding-model/Embedding.ts +23 -0
  140. package/src/embedding-model/EmbeddingModel.test.ts +92 -0
  141. package/src/embedding-model/EmbeddingModel.ts +30 -20
  142. package/src/index.ts +6 -0
  143. package/src/language-model/LanguageModel.test.ts +170 -0
  144. package/src/language-model/LanguageModel.ts +64 -1
  145. package/src/loop/Loop.test.ts +256 -3
  146. package/src/loop/Loop.ts +225 -49
  147. package/src/music-generator/MusicGenerator.test.ts +170 -0
  148. package/src/music-generator/MusicGenerator.ts +123 -0
  149. package/src/observability/Metrics.ts +1 -1
  150. package/src/speech-synthesizer/SpeechSynthesizer.test.ts +141 -0
  151. package/src/speech-synthesizer/SpeechSynthesizer.ts +131 -0
  152. package/src/streaming/JSONL.ts +16 -13
  153. package/src/structured-format/StructuredFormat.test.ts +105 -0
  154. package/src/structured-format/StructuredFormat.ts +14 -1
  155. package/src/testing/MockMusicGenerator.ts +168 -0
  156. package/src/testing/MockProvider.ts +126 -105
  157. package/src/testing/MockSpeechSynthesizer.ts +163 -0
  158. package/src/testing/MockTranscriber.ts +137 -0
  159. package/src/tool/Resolvers.test.ts +8 -5
  160. package/src/tool/Resolvers.ts +17 -19
  161. package/src/tool/Tool.test.ts +105 -0
  162. package/src/tool/Tool.ts +20 -0
  163. package/src/tool/Toolkit.ts +49 -50
  164. package/src/transcriber/Transcriber.test.ts +125 -0
  165. package/src/transcriber/Transcriber.ts +127 -0
  166. package/dist/StructuredFormat-BWq5Hd1O.d.mts.map +0 -1
  167. package/dist/Tool-DjVufH7i.d.mts.map +0 -1
  168. package/dist/Turn-OPaILVIB.d.mts +0 -194
  169. package/dist/Turn-OPaILVIB.d.mts.map +0 -1
@@ -0,0 +1,137 @@
1
+ import { Effect, Layer, Ref, Stream } from "effect"
2
+ import * as AiError from "../domain/AiError.js"
3
+ import type { TranscriptEvent, TranscriptResult } from "../domain/Transcript.js"
4
+ import {
5
+ SttStreaming,
6
+ Transcriber,
7
+ type CommonStreamTranscribeRequest,
8
+ type CommonTranscribeRequest,
9
+ type TranscriberService,
10
+ } from "../transcriber/Transcriber.js"
11
+
12
+ /**
13
+ * Recorder of every call made to the mock.
14
+ */
15
+ export type MockTranscriberRecorder = {
16
+ readonly transcribeCalls: ReadonlyArray<CommonTranscribeRequest>
17
+ readonly streamCalls: ReadonlyArray<CommonStreamTranscribeRequest>
18
+ }
19
+
20
+ export type MockTranscriberScript = {
21
+ /** One result per `transcribe` call, consumed in order. */
22
+ readonly transcripts?: ReadonlyArray<TranscriptResult>
23
+ /** One event-list per `streamTranscriptionFrom` call, consumed in order. */
24
+ readonly streams?: ReadonlyArray<ReadonlyArray<TranscriptEvent>>
25
+ }
26
+
27
+ const makeService = (
28
+ script: MockTranscriberScript,
29
+ record: {
30
+ readonly transcribe: (req: CommonTranscribeRequest) => Effect.Effect<void>
31
+ readonly stream: (req: CommonStreamTranscribeRequest) => Effect.Effect<void>
32
+ },
33
+ ) =>
34
+ Effect.gen(function* () {
35
+ const tCursor = yield* Ref.make(0)
36
+ const sCursor = yield* Ref.make(0)
37
+ const service: TranscriberService = {
38
+ transcribe: (request) =>
39
+ Effect.gen(function* () {
40
+ yield* record.transcribe(request)
41
+ const i = yield* Ref.getAndUpdate(tCursor, (n) => n + 1)
42
+ const scripted = script.transcripts ?? []
43
+ if (i >= scripted.length) {
44
+ return yield* new AiError.InvalidRequest({
45
+ provider: "mock",
46
+ raw: `MockTranscriber exhausted: ${scripted.length} transcripts scripted, but call ${i + 1} was made`,
47
+ })
48
+ }
49
+ return scripted[i]!
50
+ }),
51
+ streamTranscriptionFrom: <E, R>(
52
+ audioIn: Stream.Stream<Uint8Array, E, R>,
53
+ request: CommonStreamTranscribeRequest,
54
+ ): Stream.Stream<TranscriptEvent, AiError.AiError | E, R> =>
55
+ Stream.unwrap(
56
+ Effect.gen(function* () {
57
+ yield* record.stream(request)
58
+ const i = yield* Ref.getAndUpdate(sCursor, (n) => n + 1)
59
+ const scripted = script.streams ?? []
60
+ if (i >= scripted.length) {
61
+ const exhausted: Stream.Stream<TranscriptEvent, AiError.AiError | E, R> = Stream.fail(
62
+ new AiError.InvalidRequest({
63
+ provider: "mock",
64
+ raw: `MockTranscriber exhausted: ${scripted.length} streams scripted, but call ${i + 1} was made`,
65
+ }),
66
+ )
67
+ return exhausted
68
+ }
69
+ // Drain the input audio fully before emitting the scripted events,
70
+ // so consumers can assert on what bytes were pushed.
71
+ return Stream.drain(audioIn).pipe(Stream.concat(Stream.fromIterable(scripted[i]!)))
72
+ }),
73
+ ),
74
+ }
75
+ return service
76
+ })
77
+
78
+ /**
79
+ * Returns a Layer that provides both the `Transcriber` service and the
80
+ * `SttStreaming` capability marker. Use when the code under test calls
81
+ * `streamTranscriptionFrom`.
82
+ */
83
+ export const layer = (
84
+ script: MockTranscriberScript,
85
+ ): {
86
+ readonly layer: Layer.Layer<Transcriber | SttStreaming>
87
+ readonly recorder: Effect.Effect<MockTranscriberRecorder>
88
+ } => {
89
+ const tCalls = Ref.makeUnsafe<ReadonlyArray<CommonTranscribeRequest>>([])
90
+ const sCalls = Ref.makeUnsafe<ReadonlyArray<CommonStreamTranscribeRequest>>([])
91
+ const transcriberLayer = Layer.effect(
92
+ Transcriber,
93
+ makeService(script, {
94
+ transcribe: (req) => Ref.update(tCalls, (xs) => [...xs, req]),
95
+ stream: (req) => Ref.update(sCalls, (xs) => [...xs, req]),
96
+ }),
97
+ )
98
+ const live = Layer.merge(transcriberLayer, Layer.succeed(SttStreaming, undefined))
99
+ return {
100
+ layer: live,
101
+ recorder: Effect.gen(function* () {
102
+ const transcribeCalls = yield* Ref.get(tCalls)
103
+ const streamCalls = yield* Ref.get(sCalls)
104
+ return { transcribeCalls, streamCalls }
105
+ }),
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Variant that omits the `SttStreaming` marker — use to test that
111
+ * consumers calling `streamTranscriptionFrom` fail to compile against
112
+ * a non-streaming provider.
113
+ */
114
+ export const layerSyncOnly = (
115
+ script: MockTranscriberScript,
116
+ ): {
117
+ readonly layer: Layer.Layer<Transcriber>
118
+ readonly recorder: Effect.Effect<MockTranscriberRecorder>
119
+ } => {
120
+ const tCalls = Ref.makeUnsafe<ReadonlyArray<CommonTranscribeRequest>>([])
121
+ const sCalls = Ref.makeUnsafe<ReadonlyArray<CommonStreamTranscribeRequest>>([])
122
+ const live = Layer.effect(
123
+ Transcriber,
124
+ makeService(script, {
125
+ transcribe: (req) => Ref.update(tCalls, (xs) => [...xs, req]),
126
+ stream: (req) => Ref.update(sCalls, (xs) => [...xs, req]),
127
+ }),
128
+ )
129
+ return {
130
+ layer: live,
131
+ recorder: Effect.gen(function* () {
132
+ const transcribeCalls = yield* Ref.get(tCalls)
133
+ const streamCalls = yield* Ref.get(sCalls)
134
+ return { transcribeCalls, streamCalls }
135
+ }),
136
+ }
137
+ }
@@ -22,8 +22,8 @@ import {
22
22
  fromVerdictQueue,
23
23
  } from "./Resolvers.js"
24
24
  import { fromEffectSchema, make as makeTool, streaming } from "./Tool.js"
25
- import { executeAll, outputEvent, outputEvents } from "./Toolkit.js"
26
- import { type ToolEvent, isApprovalRequested, isIntermediate, isOutput } from "./ToolEvent.js"
25
+ import { executeAll } from "./Toolkit.js"
26
+ import { ToolEvent, isApprovalRequested, isIntermediate, isOutput } from "./ToolEvent.js"
27
27
 
28
28
  // ---------------------------------------------------------------------------
29
29
  // Three demo tools covering the matrix:
@@ -91,15 +91,18 @@ const resultsFrom = (collected: ReadonlyArray<ToolEvent>): ReadonlyArray<ToolRes
91
91
 
92
92
  const byCallId = (results: ReadonlyArray<ToolResult>) => new Map(results.map((r) => [r.call_id, r]))
93
93
 
94
+ const rejectedStream = (rejected: ReadonlyArray<ToolResult>) =>
95
+ Stream.fromIterable(rejected.map((result) => ToolEvent.Output({ result })))
96
+
94
97
  const eventsFromApprovalMap = (approvals: ReadonlyMap<string, ApprovalMapEntry>) => {
95
98
  const plan = fromApprovalMap(isSensitive, approvals)(calls)
96
- return Stream.merge(executeAll(allTools, plan.approved), outputEvents(plan.rejected))
99
+ return Stream.merge(executeAll(allTools, plan.approved), rejectedStream(plan.rejected))
97
100
  }
98
101
 
99
102
  const eventsFromDecision = (decision: ToolCallDecision): Stream.Stream<ToolEvent> =>
100
103
  decision._tag === "Approved"
101
104
  ? executeAll(allTools, [decision.call])
102
- : Stream.succeed(outputEvent(decision.result))
105
+ : Stream.succeed(ToolEvent.Output({ result: decision.result }))
103
106
 
104
107
  // ---------------------------------------------------------------------------
105
108
  // fromApprovalMap: HTTP-style scenarios
@@ -189,7 +192,7 @@ describe("executeAll: graceful degradation", () => {
189
192
  isSensitive,
190
193
  new Map([["c3", { decision: "approve" }]]),
191
194
  )(callsWithBogus)
192
- return Stream.merge(executeAll(allTools, plan.approved), outputEvents(plan.rejected))
195
+ return Stream.merge(executeAll(allTools, plan.approved), rejectedStream(plan.rejected))
193
196
  })(),
194
197
  ),
195
198
  )
@@ -5,29 +5,27 @@
5
5
  * results must be returned to the model. Tool execution stays explicit at
6
6
  * the recipe boundary via `Toolkit.executeAll`.
7
7
  */
8
- import { Deferred, Effect, Queue, Scope, Stream } from "effect"
8
+ import { Data, Deferred, Effect, Queue, Scope, Stream } from "effect"
9
9
  import type { FunctionCall } from "../domain/Items.js"
10
10
  import { type ToolResult, cancelled, denied } from "./Outcome.js"
11
- import type { ToolEvent } from "./ToolEvent.js"
11
+ import { ToolEvent } from "./ToolEvent.js"
12
12
 
13
13
  export type ToolCallPlan = {
14
14
  readonly approved: ReadonlyArray<FunctionCall>
15
15
  readonly rejected: ReadonlyArray<ToolResult>
16
16
  }
17
17
 
18
- export type ToolCallDecision =
19
- | { readonly _tag: "Approved"; readonly call: FunctionCall }
20
- | { readonly _tag: "Rejected"; readonly result: ToolResult }
18
+ export type ToolCallDecision = Data.TaggedEnum<{
19
+ Approved: { readonly call: FunctionCall }
20
+ Rejected: { readonly result: ToolResult }
21
+ }>
21
22
 
22
- export const approve = (call: FunctionCall): ToolCallDecision => ({
23
- _tag: "Approved",
24
- call,
25
- })
23
+ export const ToolCallDecision = Data.taggedEnum<ToolCallDecision>()
26
24
 
27
- export const reject = (result: ToolResult): ToolCallDecision => ({
28
- _tag: "Rejected",
29
- result,
30
- })
25
+ export const approve = (call: FunctionCall): ToolCallDecision => ToolCallDecision.Approved({ call })
26
+
27
+ export const reject = (result: ToolResult): ToolCallDecision =>
28
+ ToolCallDecision.Rejected({ result })
31
29
 
32
30
  export const splitToolCallDecisions = (decisions: ReadonlyArray<ToolCallDecision>): ToolCallPlan =>
33
31
  decisions.reduce<ToolCallPlan>(
@@ -38,12 +36,12 @@ export const splitToolCallDecisions = (decisions: ReadonlyArray<ToolCallDecision
38
36
  { approved: [], rejected: [] },
39
37
  )
40
38
 
41
- export const approvalRequested = (call: FunctionCall): ToolEvent => ({
42
- _tag: "ApprovalRequested",
43
- call_id: call.call_id,
44
- tool: call.name,
45
- arguments: call.arguments,
46
- })
39
+ export const approvalRequested = (call: FunctionCall): ToolEvent =>
40
+ ToolEvent.ApprovalRequested({
41
+ call_id: call.call_id,
42
+ tool: call.name,
43
+ arguments: call.arguments,
44
+ })
47
45
 
48
46
  // ---------------------------------------------------------------------------
49
47
  // Verdict queue (WebSocket-style transport).
@@ -0,0 +1,105 @@
1
+ import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
2
+ import { Effect } from "effect"
3
+ import { describe, expect, expectTypeOf, it } from "vitest"
4
+ import * as Tool from "./Tool.js"
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Minimal dual-standard schema for testing — no need to pull Zod / Valibot /
8
+ // ArkType as devDeps. This is the smallest possible object satisfying both
9
+ // `StandardSchemaV1` and `StandardJSONSchemaV1` per their published specs.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ type EmailRecipient = { readonly to: string }
13
+
14
+ const emailRecipientSchema: StandardSchemaV1<unknown, EmailRecipient> &
15
+ StandardJSONSchemaV1<unknown, EmailRecipient> = {
16
+ "~standard": {
17
+ version: 1,
18
+ vendor: "test-fixture",
19
+ validate: (value) => {
20
+ if (
21
+ typeof value === "object" &&
22
+ value !== null &&
23
+ "to" in value &&
24
+ typeof (value as { to: unknown }).to === "string"
25
+ ) {
26
+ return { value: value as EmailRecipient }
27
+ }
28
+ return { issues: [{ message: "expected { to: string }" }] }
29
+ },
30
+ jsonSchema: {
31
+ input: () => ({
32
+ type: "object",
33
+ properties: { to: { type: "string" } },
34
+ required: ["to"],
35
+ }),
36
+ output: () => ({
37
+ type: "object",
38
+ properties: { to: { type: "string" } },
39
+ required: ["to"],
40
+ }),
41
+ },
42
+ },
43
+ }
44
+
45
+ // A schema that satisfies StandardSchemaV1 only (no JSON Schema). Used to
46
+ // verify the helper's compile-time guard.
47
+ const standardOnly: StandardSchemaV1<unknown, EmailRecipient> = {
48
+ "~standard": {
49
+ version: 1,
50
+ vendor: "test-fixture",
51
+ validate: () => ({ value: { to: "" } }),
52
+ },
53
+ }
54
+
55
+ describe("Tool.fromStandardSchema", () => {
56
+ it("returns the schema (structurally) typed as ToolInputSchema<Output>", () => {
57
+ const adapted = Tool.fromStandardSchema(emailRecipientSchema)
58
+
59
+ // Same object — helper is a type-narrowing identity at runtime.
60
+ expect(adapted).toBe(emailRecipientSchema)
61
+
62
+ // Both interfaces accessible through the same `~standard` key.
63
+ const valid = adapted["~standard"].validate({ to: "hi@example.com" })
64
+ expect(valid).toEqual({ value: { to: "hi@example.com" } })
65
+
66
+ const json = adapted["~standard"].jsonSchema.input({ target: "draft-2020-12" })
67
+ expect(json).toEqual({
68
+ type: "object",
69
+ properties: { to: { type: "string" } },
70
+ required: ["to"],
71
+ })
72
+ })
73
+
74
+ it("composes with Tool.make so Input is inferred from the schema's Output", async () => {
75
+ const sendEmail = Tool.make({
76
+ name: "send_email",
77
+ description: "Send an email to a single recipient.",
78
+ inputSchema: Tool.fromStandardSchema(emailRecipientSchema),
79
+ run: ({ to }) => Effect.succeed(`queued: ${to}`),
80
+ })
81
+
82
+ // `run`'s parameter is typed as { to: string } via the schema's Output —
83
+ // this property access compiles without annotation.
84
+ const result = await Effect.runPromise(sendEmail.run({ to: "x@y.z" }))
85
+ expect(result).toBe("queued: x@y.z")
86
+ })
87
+
88
+ it("type: rejects schemas missing the Standard JSON Schema half at compile time", () => {
89
+ // @ts-expect-error — `standardOnly` lacks `jsonSchema`; helper's
90
+ // intersection constraint refuses it.
91
+ Tool.fromStandardSchema(standardOnly)
92
+ })
93
+
94
+ it("type: Output type flows through fromStandardSchema into Tool.make", () => {
95
+ const tool = Tool.make({
96
+ name: "send_email",
97
+ description: "send",
98
+ inputSchema: Tool.fromStandardSchema(emailRecipientSchema),
99
+ run: (input) => Effect.succeed(input),
100
+ })
101
+
102
+ type InputOf<T> = T extends Tool.Tool<string, infer I, unknown, never> ? I : never
103
+ expectTypeOf<InputOf<typeof tool>>().toEqualTypeOf<EmailRecipient>()
104
+ })
105
+ })
package/src/tool/Tool.ts CHANGED
@@ -36,6 +36,26 @@ export const fromEffectSchema = <S extends Schema.Codec<any, any, never, any>>(
36
36
  Schema.toStandardJSONSchemaV1(Schema.toStandardSchemaV1(schema)) as unknown as S &
37
37
  ToolInputSchema<S["Type"]>
38
38
 
39
+ /**
40
+ * Use any schema library that implements both Standard Schema (validation)
41
+ * and Standard JSON Schema (JSON Schema generation) as a `Tool.inputSchema`.
42
+ * Covers Zod 4.2+, Valibot 1.2+, and ArkType 2.1.28+ in one helper.
43
+ *
44
+ * Effect Schema doesn't implement Standard JSON Schema natively — use
45
+ * `fromEffectSchema` for those.
46
+ *
47
+ * The intersection constraint catches missing interfaces at compile time:
48
+ * a Zod v3 schema (no Standard JSON Schema) produces a precise type error
49
+ * pointing at the missing interface rather than a runtime surprise. The
50
+ * helper itself is a thin type-narrowing identity — schemas that satisfy
51
+ * both standards already structurally satisfy `ToolInputSchema`; the
52
+ * helper makes the input type inference explicit at the call site.
53
+ */
54
+ export const fromStandardSchema = <S extends StandardSchemaV1 & StandardJSONSchemaV1>(
55
+ schema: S,
56
+ ): S & ToolInputSchema<StandardSchemaV1.InferOutput<S>> =>
57
+ schema as S & ToolInputSchema<StandardSchemaV1.InferOutput<S>>
58
+
39
59
  export type Tool<Name extends string, Input, Output, R = never> = {
40
60
  readonly name: Name
41
61
  readonly description: string
@@ -1,4 +1,4 @@
1
- import { Array as Arr, Effect, Function, Ref, Stream } from "effect"
1
+ import { Array as Arr, Effect, Function, Ref, Schema, Stream } from "effect"
2
2
  import * as Loop from "../loop/Loop.js"
3
3
  import type { FunctionCall } from "../domain/Items.js"
4
4
  import {
@@ -10,8 +10,8 @@ import {
10
10
  type Tool,
11
11
  type ToolDescriptor,
12
12
  } from "./Tool.js"
13
- import { type ToolResult, executionError, rejected } from "./Outcome.js"
14
- import type { ToolEvent } from "./ToolEvent.js"
13
+ import { ToolResult, executionError, rejected } from "./Outcome.js"
14
+ import { ToolEvent } from "./ToolEvent.js"
15
15
  import { isOutput } from "./ToolEvent.js"
16
16
 
17
17
  export type AnyTool = Tool<string, any, any, any>
@@ -52,7 +52,12 @@ export const toDescriptors = <Tools extends ReadonlyArray<AnyTool>>(
52
52
  target: "draft-2020-12",
53
53
  })
54
54
  return tool.strict !== undefined
55
- ? { name: tool.name, description: tool.description, inputSchema, strict: tool.strict }
55
+ ? {
56
+ name: tool.name,
57
+ description: tool.description,
58
+ inputSchema,
59
+ strict: tool.strict,
60
+ }
56
61
  : { name: tool.name, description: tool.description, inputSchema }
57
62
  })
58
63
 
@@ -78,17 +83,12 @@ export const executeAll = <Tools extends ReadonlyArray<AnyKindTool<any>>>(
78
83
  }),
79
84
  )
80
85
 
81
- export const outputEvent = (result: ToolResult): ToolEvent => ({ _tag: "Output", result })
82
-
83
- export const outputEvents = (results: ReadonlyArray<ToolResult>): Stream.Stream<ToolEvent> =>
84
- Stream.fromIterable(results.map(outputEvent))
85
-
86
- const valueResult = (call: FunctionCall, tool: string, value: unknown): ToolResult => ({
87
- _tag: "Value",
88
- call_id: call.call_id,
89
- tool,
90
- value,
91
- })
86
+ const valueResult = (call: FunctionCall, tool: string, value: unknown): ToolResult =>
87
+ ToolResult.Value({
88
+ call_id: call.call_id,
89
+ tool,
90
+ value,
91
+ })
92
92
 
93
93
  const runOne = <R>(
94
94
  tools: ReadonlyArray<AnyKindTool<R>>,
@@ -98,25 +98,27 @@ const runOne = <R>(
98
98
  if (tool === undefined) {
99
99
  // Graceful: emit a synthetic Failure so OTHER calls in this turn
100
100
  // still execute. LLMs hallucinate tool names; MCP tools come and go.
101
- return Stream.succeed<ToolEvent>({
102
- _tag: "Output",
103
- result: rejected(call, "unknown_tool", `No tool registered with name "${call.name}"`),
104
- })
101
+ return Stream.succeed(
102
+ ToolEvent.Output({
103
+ result: rejected(call, "unknown_tool", `No tool registered with name "${call.name}"`),
104
+ }),
105
+ )
105
106
  }
106
107
  if (isStreamingTool(tool)) return runStreaming(tool, call)
107
108
  return runPlain(tool, call)
108
109
  }
109
110
 
111
+ const parseJsonUnknown = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
112
+
110
113
  const runPlain = <R>(
111
114
  tool: AnyPlainTool<R>,
112
115
  call: FunctionCall,
113
116
  ): Stream.Stream<ToolEvent, never, R> =>
114
117
  Stream.fromEffect(
115
118
  Effect.gen(function* () {
116
- const parsed = yield* Effect.try({
117
- try: () => JSON.parse(call.arguments) as unknown,
118
- catch: () => "json_parse_error" as const,
119
- })
119
+ const parsed = yield* parseJsonUnknown(call.arguments).pipe(
120
+ Effect.mapError(() => "json_parse_error" as const),
121
+ )
120
122
  const validated = yield* Effect.tryPromise({
121
123
  try: () => Promise.resolve(tool.inputSchema["~standard"].validate(parsed)),
122
124
  catch: () => "validation_threw" as const,
@@ -128,7 +130,7 @@ const runPlain = <R>(
128
130
  return valueResult(call, tool.name, output)
129
131
  }).pipe(
130
132
  Effect.catchCause(() => Effect.succeed(executionError(call, "Tool execution failed"))),
131
- Effect.map((result) => ({ _tag: "Output", result }) satisfies ToolEvent),
133
+ Effect.map((result) => ToolEvent.Output({ result })),
132
134
  ),
133
135
  )
134
136
 
@@ -138,19 +140,19 @@ const runStreaming = <R>(
138
140
  ): Stream.Stream<ToolEvent, never, R> =>
139
141
  Stream.unwrap(
140
142
  Effect.gen(function* () {
141
- const parsed = yield* Effect.try({
142
- try: () => JSON.parse(call.arguments) as unknown,
143
- catch: () => "json_parse_error" as const,
144
- })
143
+ const parsed = yield* parseJsonUnknown(call.arguments).pipe(
144
+ Effect.mapError(() => "json_parse_error" as const),
145
+ )
145
146
  const validated = yield* Effect.tryPromise({
146
147
  try: () => Promise.resolve(tool.inputSchema["~standard"].validate(parsed)),
147
148
  catch: () => "validation_threw" as const,
148
149
  })
149
150
  if (validated.issues !== undefined) {
150
- return Stream.succeed<ToolEvent>({
151
- _tag: "Output",
152
- result: executionError(call, "Tool input failed schema validation"),
153
- })
151
+ return Stream.succeed<ToolEvent>(
152
+ ToolEvent.Output({
153
+ result: executionError(call, "Tool input failed schema validation"),
154
+ }),
155
+ )
154
156
  }
155
157
 
156
158
  // Real-time: tap each event into a Ref as it flows; emit one
@@ -159,24 +161,20 @@ const runStreaming = <R>(
159
161
  const ref = yield* Ref.make<Array<unknown>>([])
160
162
  const intermediates = tool.run(validated.value).pipe(
161
163
  Stream.tap((event) => Ref.update(ref, Arr.append(event))),
162
- Stream.map(
163
- (data) =>
164
- ({
165
- _tag: "Intermediate",
166
- call_id: call.call_id,
167
- tool: tool.name,
168
- data,
169
- }) satisfies ToolEvent,
164
+ Stream.map((data) =>
165
+ ToolEvent.Intermediate({
166
+ call_id: call.call_id,
167
+ tool: tool.name,
168
+ data,
169
+ }),
170
170
  ),
171
171
  )
172
172
  const output = Stream.fromEffect(
173
173
  Ref.get(ref).pipe(
174
- Effect.map(
175
- (events) =>
176
- ({
177
- _tag: "Output",
178
- result: valueResult(call, tool.name, tool.finalize(events)),
179
- }) satisfies ToolEvent,
174
+ Effect.map((events) =>
175
+ ToolEvent.Output({
176
+ result: valueResult(call, tool.name, tool.finalize(events)),
177
+ }),
180
178
  ),
181
179
  ),
182
180
  )
@@ -184,10 +182,11 @@ const runStreaming = <R>(
184
182
  }),
185
183
  ).pipe(
186
184
  Stream.catchCause(() =>
187
- Stream.succeed<ToolEvent>({
188
- _tag: "Output",
189
- result: executionError(call, "Tool execution failed"),
190
- }),
185
+ Stream.succeed(
186
+ ToolEvent.Output({
187
+ result: executionError(call, "Tool execution failed"),
188
+ }),
189
+ ),
191
190
  ),
192
191
  )
193
192
 
@@ -0,0 +1,125 @@
1
+ import { Effect, Stream } from "effect"
2
+ import { describe, expect, expectTypeOf, it } from "vitest"
3
+ import type * as AiError from "../domain/AiError.js"
4
+ import type { TranscriptEvent, TranscriptResult } from "../domain/Transcript.js"
5
+ import * as MockTranscriber from "../testing/MockTranscriber.js"
6
+ import * as Transcriber from "./Transcriber.js"
7
+
8
+ describe("Transcriber.transcribe", () => {
9
+ it("returns the scripted TranscriptResult", async () => {
10
+ const mock = MockTranscriber.layer({
11
+ transcripts: [{ text: "hello world", durationSeconds: 1.23 }],
12
+ })
13
+ const program = Transcriber.transcribe({
14
+ audio: { _tag: "bytes", bytes: new Uint8Array([0]), mimeType: "audio/wav" },
15
+ model: "mock-stt",
16
+ })
17
+ const result = await Effect.runPromise(program.pipe(Effect.provide(mock.layer)))
18
+ expect(result.text).toBe("hello world")
19
+ expect(result.durationSeconds).toBe(1.23)
20
+ })
21
+
22
+ it("records each transcribe call", async () => {
23
+ const mock = MockTranscriber.layer({
24
+ transcripts: [{ text: "a" }, { text: "b" }],
25
+ })
26
+ const program = Effect.gen(function* () {
27
+ yield* Transcriber.transcribe({
28
+ audio: { _tag: "bytes", bytes: new Uint8Array([1]), mimeType: "audio/wav" },
29
+ model: "m1",
30
+ })
31
+ yield* Transcriber.transcribe({
32
+ audio: { _tag: "bytes", bytes: new Uint8Array([2]), mimeType: "audio/wav" },
33
+ model: "m2",
34
+ })
35
+ return yield* mock.recorder
36
+ })
37
+ const rec = await Effect.runPromise(program.pipe(Effect.provide(mock.layer)))
38
+ expect(rec.transcribeCalls.map((c) => c.model)).toEqual(["m1", "m2"])
39
+ })
40
+ })
41
+
42
+ describe("Transcriber capability marker (compile-time)", () => {
43
+ const sttReq: Transcriber.CommonStreamTranscribeRequest = {
44
+ model: "mock-stt",
45
+ inputFormat: { container: "raw", encoding: "pcm_s16le", sampleRate: 16000 },
46
+ }
47
+
48
+ it("requires `SttStreaming` on the R channel of streamTranscriptionFrom", () => {
49
+ const audio: Stream.Stream<Uint8Array> = Stream.fromIterable([new Uint8Array([0])])
50
+ const events = audio.pipe(Transcriber.streamTranscriptionFrom(sttReq))
51
+ expectTypeOf(events).toEqualTypeOf<
52
+ Stream.Stream<
53
+ TranscriptEvent,
54
+ AiError.AiError,
55
+ Transcriber.Transcriber | Transcriber.SttStreaming
56
+ >
57
+ >()
58
+ })
59
+
60
+ it("does NOT require `SttStreaming` for sync `transcribe`", () => {
61
+ const eff = Transcriber.transcribe({
62
+ audio: { _tag: "bytes", bytes: new Uint8Array([0]), mimeType: "audio/wav" },
63
+ model: "m",
64
+ })
65
+ expectTypeOf(eff).toEqualTypeOf<
66
+ Effect.Effect<TranscriptResult, AiError.AiError, Transcriber.Transcriber>
67
+ >()
68
+ })
69
+
70
+ it("a sync-only layer leaves `SttStreaming` unsatisfied in R", () => {
71
+ const syncOnly = MockTranscriber.layerSyncOnly({})
72
+ const audio: Stream.Stream<Uint8Array> = Stream.fromIterable([new Uint8Array([0])])
73
+ const events = audio.pipe(Transcriber.streamTranscriptionFrom(sttReq))
74
+ const program = Stream.runDrain(events).pipe(Effect.provide(syncOnly.layer))
75
+ // `Transcriber` is provided by syncOnly.layer; `SttStreaming` is not.
76
+ expectTypeOf(program).toEqualTypeOf<
77
+ Effect.Effect<void, AiError.AiError, Transcriber.SttStreaming>
78
+ >()
79
+ })
80
+
81
+ it("a full layer (with marker) clears R to never", () => {
82
+ const fullMock = MockTranscriber.layer({ streams: [[]] })
83
+ const audio: Stream.Stream<Uint8Array> = Stream.fromIterable([new Uint8Array([0])])
84
+ const events = audio.pipe(Transcriber.streamTranscriptionFrom(sttReq))
85
+ const program = Stream.runDrain(events).pipe(Effect.provide(fullMock.layer))
86
+ expectTypeOf(program).toEqualTypeOf<Effect.Effect<void, AiError.AiError, never>>()
87
+ })
88
+ })
89
+
90
+ describe("Transcriber.streamTranscriptionFrom", () => {
91
+ const sttReq: Transcriber.CommonStreamTranscribeRequest = {
92
+ model: "mock-stt",
93
+ inputFormat: { container: "raw", encoding: "pcm_s16le", sampleRate: 16000 },
94
+ }
95
+
96
+ it("emits scripted events after draining the input audio stream", async () => {
97
+ const mock = MockTranscriber.layer({
98
+ streams: [
99
+ [
100
+ { _tag: "partial", text: "hello" },
101
+ { _tag: "final", text: "hello world" },
102
+ ],
103
+ ],
104
+ })
105
+ const audio = Stream.fromIterable([new Uint8Array([0, 1, 2]), new Uint8Array([3, 4, 5])])
106
+ const events = audio.pipe(Transcriber.streamTranscriptionFrom(sttReq))
107
+ const collected = await Effect.runPromise(
108
+ Stream.runCollect(events).pipe(Effect.provide(mock.layer)),
109
+ )
110
+ expect(collected).toEqual([
111
+ { _tag: "partial", text: "hello" },
112
+ { _tag: "final", text: "hello world" },
113
+ ])
114
+ })
115
+
116
+ it("works data-first (direct call) as well as pipeable (data-last)", async () => {
117
+ const mock = MockTranscriber.layer({
118
+ streams: [[{ _tag: "final", text: "x" }]],
119
+ })
120
+ const audio = Stream.fromIterable([new Uint8Array([0])])
121
+ const events = Transcriber.streamTranscriptionFrom(audio, sttReq)
122
+ const out = await Effect.runPromise(Stream.runCollect(events).pipe(Effect.provide(mock.layer)))
123
+ expect(out).toEqual([{ _tag: "final", text: "x" }])
124
+ })
125
+ })