@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
@@ -1,11 +1,29 @@
1
1
  import { Context, Effect, Layer, pipe, Schema, Stream } from "effect"
2
2
  import { describe, expect, expectTypeOf, it } from "vitest"
3
+ import * as AiError from "../domain/AiError.js"
3
4
  import type { ToolCall } from "../domain/Items.js"
4
- import { isOutput } from "./ToolEvent.js"
5
- import { isOk } from "./ToolResult.js"
5
+ import { type ToolEvent, isOutput, isProgress } from "./ToolEvent.js"
6
+ import { isFailure, isOk, toToolCallOutput } from "./ToolResult.js"
6
7
  import * as Tool from "./Tool.js"
7
8
  import * as Toolkit from "./Toolkit.js"
8
9
 
10
+ const Empty = Schema.Struct({})
11
+ const toolCall = (name: string, id: string, args = "{}"): ToolCall => ({
12
+ type: "function_call",
13
+ call_id: id,
14
+ name,
15
+ arguments: args,
16
+ })
17
+ const runToArray = <T extends Toolkit.Toolkit>(
18
+ toolkit: T,
19
+ calls: ReadonlyArray<ToolCall>,
20
+ options?: Toolkit.ExecuteOptions,
21
+ ) =>
22
+ Toolkit.run(toolkit, calls, options).pipe(
23
+ Stream.runCollect,
24
+ Effect.map((events): ReadonlyArray<ToolEvent> => Array.from(events)),
25
+ )
26
+
9
27
  describe("Tool.toDescriptors", () => {
10
28
  const GetWeatherInput = Schema.Struct({ city: Schema.String })
11
29
 
@@ -234,6 +252,66 @@ describe("Toolkit.make / compose / namespace - uniqueness", () => {
234
252
  })
235
253
  })
236
254
 
255
+ describe("Toolkit.namespace - typed toolkits", () => {
256
+ const Query = Schema.Struct({ q: Schema.String })
257
+ type GeoShape = { readonly lookup: (q: string) => Effect.Effect<string> }
258
+ class Geo extends Context.Service<Geo, GeoShape>()("test/Geo") {}
259
+
260
+ const search = Tool.make({
261
+ name: "search",
262
+ description: "",
263
+ inputSchema: Tool.fromEffectSchema(Query),
264
+ run: ({ q }) =>
265
+ Effect.gen(function* () {
266
+ const geo = yield* Geo
267
+ if (q === "") return yield* Effect.fail("empty_query" as const)
268
+ return yield* geo.lookup(q)
269
+ }),
270
+ })
271
+
272
+ const kit = Toolkit.namespace("geo", Toolkit.make(search))
273
+
274
+ it("type: prefixes the name literal of a service-requiring tool", () => {
275
+ expectTypeOf(kit.geo__search.name).toEqualTypeOf<"geo__search">()
276
+ })
277
+
278
+ it("type: preserves E and R through namespace", () => {
279
+ expectTypeOf<Toolkit.ToolkitE<typeof kit>>().toEqualTypeOf<"empty_query">()
280
+ expectTypeOf<Toolkit.ToolkitR<typeof kit>>().toEqualTypeOf<Geo>()
281
+ })
282
+
283
+ it("type: preserves E and R through makeNamespaced", () => {
284
+ const namespaced = Toolkit.makeNamespaced("geo", search)
285
+ expectTypeOf(namespaced.geo__search.name).toEqualTypeOf<"geo__search">()
286
+ expectTypeOf<Toolkit.ToolkitE<typeof namespaced>>().toEqualTypeOf<"empty_query">()
287
+ expectTypeOf<Toolkit.ToolkitR<typeof namespaced>>().toEqualTypeOf<Geo>()
288
+ })
289
+
290
+ it("type: Toolkit.run surfaces the namespaced tool's R (string E is absorbed)", () => {
291
+ const stream = Toolkit.run(kit, [])
292
+ expectTypeOf(stream).toEqualTypeOf<
293
+ Stream.Stream<import("./ToolEvent.js").ToolEvent, never, Geo>
294
+ >()
295
+ })
296
+
297
+ it("runtime: renames the tool and executes it under the prefixed name", async () => {
298
+ const call: ToolCall = {
299
+ type: "function_call",
300
+ call_id: "call_1",
301
+ name: "geo__search",
302
+ arguments: '{"q":"lisbon"}',
303
+ }
304
+ const events = await Effect.runPromise(
305
+ Stream.runCollect(Toolkit.run(kit, [call])).pipe(
306
+ Effect.provide(Layer.succeed(Geo, { lookup: (q) => Effect.succeed(`geo:${q}`) })),
307
+ ),
308
+ )
309
+ const outputs = events.filter(isOutput).map((e) => e.result)
310
+ expect(outputs).toHaveLength(1)
311
+ expect(outputs[0]).toMatchObject({ _tag: "Ok", tool: "geo__search", value: "geo:lisbon" })
312
+ })
313
+ })
314
+
237
315
  describe("Toolkit.wrap - middleware", () => {
238
316
  const Empty = Schema.Struct({})
239
317
  type AuditShape = { readonly record: (name: string) => Effect.Effect<void> }
@@ -299,3 +377,154 @@ describe("Toolkit.wrap - middleware", () => {
299
377
  expect(s?._tag === "Failure" && s.kind).toBe("non_local_tool")
300
378
  })
301
379
  })
380
+
381
+ describe("Toolkit.run - executor outcomes", () => {
382
+ const plain = <N extends string>(name: N, out: unknown) =>
383
+ Tool.make({
384
+ name,
385
+ description: "",
386
+ inputSchema: Tool.fromEffectSchema(Empty),
387
+ run: () => Effect.succeed(out),
388
+ })
389
+
390
+ it("absorbs a bare string failure as a tool_failed result", async () => {
391
+ const t = Tool.make({
392
+ name: "t",
393
+ description: "",
394
+ inputSchema: Tool.fromEffectSchema(Empty),
395
+ run: () => Effect.fail("nope"),
396
+ })
397
+ const events = await Effect.runPromise(runToArray(Toolkit.make(t), [toolCall("t", "1")]))
398
+ const result = events.filter(isOutput)[0]?.result
399
+ expect(result).toMatchObject({ _tag: "Failure", kind: "tool_failed", reason: "nope" })
400
+ })
401
+
402
+ it("absorbs Tool.fail with a custom kind", async () => {
403
+ const t = Tool.make({
404
+ name: "t",
405
+ description: "",
406
+ inputSchema: Tool.fromEffectSchema(Empty),
407
+ run: () => Tool.fail("missing", { kind: "not_found" }),
408
+ })
409
+ const events = await Effect.runPromise(runToArray(Toolkit.make(t), [toolCall("t", "1")]))
410
+ const result = events.filter(isOutput)[0]?.result
411
+ expect(result).toMatchObject({ _tag: "Failure", kind: "not_found", reason: "missing" })
412
+ })
413
+
414
+ it("propagates a typed non-sentinel failure on the stream", async () => {
415
+ const t = Tool.make({
416
+ name: "t",
417
+ description: "",
418
+ inputSchema: Tool.fromEffectSchema(Empty),
419
+ run: () => Effect.fail(new AiError.RateLimited({ provider: "x", raw: null })),
420
+ })
421
+ const exit = await Effect.runPromiseExit(runToArray(Toolkit.make(t), [toolCall("t", "1")]))
422
+ expect(exit._tag).toBe("Failure")
423
+ })
424
+
425
+ it("dies on a defect instead of masking it as a result", async () => {
426
+ const t = Tool.make({
427
+ name: "t",
428
+ description: "",
429
+ inputSchema: Tool.fromEffectSchema(Empty),
430
+ run: () => Effect.die(new Error("boom")),
431
+ })
432
+ const exit = await Effect.runPromiseExit(runToArray(Toolkit.make(t), [toolCall("t", "1")]))
433
+ expect(exit._tag).toBe("Failure")
434
+ })
435
+
436
+ it("streams progress events strictly before the terminal output", async () => {
437
+ const t = Tool.make({
438
+ name: "t",
439
+ description: "",
440
+ inputSchema: Tool.fromEffectSchema(Empty),
441
+ run: (_input, emit) =>
442
+ emit("a").pipe(Effect.andThen(emit("b")), Effect.andThen(Effect.succeed("done"))),
443
+ })
444
+ const events = await Effect.runPromise(runToArray(Toolkit.make(t), [toolCall("t", "1")]))
445
+ expect(events.map((e) => e._tag)).toEqual(["Progress", "Progress", "Output"])
446
+ expect(events.filter(isProgress).map((p) => p.data)).toEqual(["a", "b"])
447
+ })
448
+
449
+ it("reports input validation failures as a result carrying the issue detail", async () => {
450
+ const t = Tool.make({
451
+ name: "t",
452
+ description: "",
453
+ inputSchema: Tool.fromEffectSchema(Schema.Struct({ x: Schema.Number })),
454
+ run: () => Effect.succeed(1),
455
+ })
456
+ const events = await Effect.runPromise(
457
+ runToArray(Toolkit.make(t), [toolCall("t", "1", '{"x":"nope"}')]),
458
+ )
459
+ const result = events.filter(isOutput)[0]?.result
460
+ expect(result).toMatchObject({ _tag: "Failure", kind: "input_validation_error" })
461
+ expect(result !== undefined && isFailure(result) && result.reason).toBeTruthy()
462
+ })
463
+
464
+ it("normalizes empty arguments to {} for a zero-arg tool", async () => {
465
+ const events = await Effect.runPromise(
466
+ runToArray(Toolkit.make(plain("t", "ok")), [toolCall("t", "1", "")]),
467
+ )
468
+ const result = events.filter(isOutput)[0]?.result
469
+ expect(result).toMatchObject({ _tag: "Ok", value: "ok" })
470
+ })
471
+
472
+ it("reports unparseable arguments as input_validation_error", async () => {
473
+ const events = await Effect.runPromise(
474
+ runToArray(Toolkit.make(plain("t", "ok")), [toolCall("t", "1", "not json")]),
475
+ )
476
+ const result = events.filter(isOutput)[0]?.result
477
+ expect(result).toMatchObject({ _tag: "Failure", kind: "input_validation_error" })
478
+ })
479
+
480
+ it("a call named constructor is unknown_tool, not non_local_tool", async () => {
481
+ const events = await Effect.runPromise(
482
+ runToArray(Toolkit.make(plain("t", "ok")), [toolCall("constructor", "1")]),
483
+ )
484
+ const result = events.filter(isOutput)[0]?.result
485
+ expect(result).toMatchObject({ _tag: "Failure", kind: "unknown_tool" })
486
+ })
487
+
488
+ it('serializes an undefined output as "null" on the wire', async () => {
489
+ const t = Tool.make({
490
+ name: "t",
491
+ description: "",
492
+ inputSchema: Tool.fromEffectSchema(Empty),
493
+ run: () => Effect.void,
494
+ })
495
+ const events = await Effect.runPromise(runToArray(Toolkit.make(t), [toolCall("t", "1")]))
496
+ const result = events.filter(isOutput)[0]?.result
497
+ expect(result !== undefined && toToolCallOutput(result).output).toBe("null")
498
+ })
499
+
500
+ it("preserves call order with concurrency 1", async () => {
501
+ const kit = Toolkit.make(plain("a", "A"), plain("b", "B"))
502
+ const events = await Effect.runPromise(
503
+ runToArray(kit, [toolCall("a", "a1"), toolCall("b", "b1")], { concurrency: 1 }),
504
+ )
505
+ expect(events.filter(isOutput).map((e) => e.result.call_id)).toEqual(["a1", "b1"])
506
+ })
507
+
508
+ it("type: run of a describeFailures toolkit has no stream error", () => {
509
+ const t = Tool.make({
510
+ name: "t",
511
+ description: "",
512
+ inputSchema: Tool.fromEffectSchema(Empty),
513
+ run: () => Effect.fail(new AiError.RateLimited({ provider: "x", raw: null })),
514
+ })
515
+ const kit = Toolkit.describeFailures(Toolkit.make(t), AiError.describe)
516
+ const stream = Toolkit.run(kit, [])
517
+ expectTypeOf(stream).toEqualTypeOf<Stream.Stream<ToolEvent, never, never>>()
518
+ })
519
+
520
+ it("type: run of a typed toolkit propagates the tool's E", () => {
521
+ const t = Tool.make({
522
+ name: "t",
523
+ description: "",
524
+ inputSchema: Tool.fromEffectSchema(Empty),
525
+ run: () => Effect.fail(new AiError.RateLimited({ provider: "x", raw: null })),
526
+ })
527
+ const stream = Toolkit.run(Toolkit.make(t), [])
528
+ expectTypeOf(stream).toEqualTypeOf<Stream.Stream<ToolEvent, AiError.RateLimited, never>>()
529
+ })
530
+ })
@@ -1,3 +1,4 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec"
1
2
  import {
2
3
  Array as Arr,
3
4
  Cause,
@@ -10,7 +11,6 @@ import {
10
11
  Queue,
11
12
  Record,
12
13
  Ref,
13
- Schema,
14
14
  Stream,
15
15
  } from "effect"
16
16
  import * as Loop from "../loop/Loop.js"
@@ -19,18 +19,21 @@ import { appendToHistory, type Turn } from "../domain/Turn.js"
19
19
  import {
20
20
  type AnyLocalTool,
21
21
  type AnyTool,
22
+ type DecodeResult,
23
+ decodeCallInput,
22
24
  type Emit,
23
25
  type InteractionTool,
24
26
  type ProviderTool,
25
27
  type SignalTool,
26
28
  type Tool,
27
29
  type ToolDescriptor,
30
+ type ToolE,
31
+ ToolFailed,
28
32
  type ToolR,
29
33
  toDescriptors,
30
34
  } from "./Tool.js"
31
35
  import {
32
36
  ToolResult,
33
- executionError,
34
37
  failed,
35
38
  nonLocalTool,
36
39
  toToolCallOutput,
@@ -93,6 +96,15 @@ export const descriptors = (toolkit: Toolkit): ReadonlyArray<ToolDescriptor> =>
93
96
  */
94
97
  export type ToolkitR<T extends Toolkit> = ToolMap extends T ? never : ToolR<T[keyof T]>
95
98
 
99
+ /**
100
+ * Union of every tool's `E` error type in a toolkit. `Toolkit.run` reports
101
+ * `Exclude<ToolkitE<T>, string | ToolFailed>` on its stream: the sentinel
102
+ * failures are absorbed into results, everything else propagates typed. The
103
+ * same `ToolMap extends T` guard as `ToolkitR` keeps a wide toolkit at
104
+ * `never` rather than `any`.
105
+ */
106
+ export type ToolkitE<T extends Toolkit> = ToolMap extends T ? never : ToolE<T[keyof T]>
107
+
96
108
  // ---------------------------------------------------------------------------
97
109
  // Uniqueness & composition. `make` rejects duplicate literal names at compile
98
110
  // time; `compose` rejects cross-source collisions — at compile time when the
@@ -174,8 +186,8 @@ export class DuplicateToolName extends Data.TaggedError("DuplicateToolName")<{
174
186
  type PrefixName<Prefix extends string, Name extends string> = `${Prefix}__${Name}`
175
187
 
176
188
  type PrefixTool<Prefix extends string, T extends AnyTool> =
177
- T extends Tool<infer N, infer I, infer Ev, infer O, infer R>
178
- ? Tool<PrefixName<Prefix, N>, I, Ev, O, R>
189
+ T extends Tool<infer N, infer I, infer Ev, infer O, infer E, infer R>
190
+ ? Tool<PrefixName<Prefix, N>, I, Ev, O, E, R>
179
191
  : T extends ProviderTool<infer N, infer I, infer P, infer C>
180
192
  ? ProviderTool<PrefixName<Prefix, N>, I, P, C>
181
193
  : T extends SignalTool<infer N, infer I>
@@ -257,45 +269,99 @@ export const compose = <const Kits extends ReadonlyArray<Toolkit>>(
257
269
  // widening every tool's type.
258
270
  // ---------------------------------------------------------------------------
259
271
 
260
- type Handler<Input, Event, Output, R> = (
272
+ type Handler<Input, Event, Output, E, R> = (
261
273
  input: Input,
262
274
  emit: Emit<Event>,
263
- ) => Effect.Effect<Output, unknown, R>
275
+ ) => Effect.Effect<Output, E, R>
264
276
 
265
- export type Middleware<R2 = never> = <Input, Event, Output, R>(
266
- run: Handler<Input, Event, Output, R>,
277
+ /**
278
+ * A `run` transform, tracking an added requirement `R2` and optional added
279
+ * error `E2`. The tool's own `E` is preserved; `describeFailures` is the
280
+ * combinator that *replaces* it (mapping every error to `string`).
281
+ */
282
+ export type Middleware<R2 = never, E2 = never> = <Input, Event, Output, E, R>(
283
+ run: Handler<Input, Event, Output, E, R>,
267
284
  name: string,
268
- ) => Handler<Input, Event, Output, R | R2>
285
+ ) => Handler<Input, Event, Output, E | E2, R | R2>
269
286
 
270
- type WrapTool<T extends AnyTool, R2> =
271
- T extends Tool<infer N, infer I, infer Ev, infer O, infer R> ? Tool<N, I, Ev, O, R | R2> : T
287
+ type WrapTool<T extends AnyTool, R2, E2> =
288
+ T extends Tool<infer N, infer I, infer Ev, infer O, infer E, infer R>
289
+ ? Tool<N, I, Ev, O, E | E2, R | R2>
290
+ : T
272
291
 
273
- type WrapToolkit<T extends ToolMap, R2> = {
274
- readonly [K in keyof T]: T[K] extends AnyTool ? WrapTool<T[K], R2> : T[K]
292
+ type WrapToolkit<T extends ToolMap, R2, E2> = {
293
+ readonly [K in keyof T]: T[K] extends AnyTool ? WrapTool<T[K], R2, E2> : T[K]
275
294
  }
276
295
 
277
296
  /**
278
297
  * Wrap every local tool's `run` with a middleware, preserving each tool's
279
298
  * `Input`/`Output` and unioning the middleware's requirement `R2` into the
280
299
  * toolkit's `ToolkitR`. Non-local kinds pass through unchanged. Stack with
281
- * `pipe`:
300
+ * `pipe` - inner-first, so the last `wrap` applied is the outermost and runs
301
+ * first:
282
302
  *
283
303
  * const observed = pipe(toolkit, Toolkit.wrap(logging), Toolkit.wrap(authz))
284
- * Toolkit.run(observed, calls) // R = ToolkitR<typeof toolkit> | <authz R>
304
+ * Toolkit.run(observed, calls) // authz runs, then logging, then the tool
285
305
  *
286
- * A middleware that `Effect.fail`s surfaces as a `ToolResult.Failure` from the
287
- * executor like any run failure; use the `Approval` flow for a distinct
288
- * "denied" verdict.
306
+ * A middleware that fails with `string` / `ToolFailed` is absorbed by the
307
+ * executor into a `ToolResult.Failure`; any other error propagates typed. Use
308
+ * `describeFailures` for the total "everything to the model" case, or the
309
+ * `Approval` flow for a distinct "denied" verdict.
289
310
  */
290
311
  export const wrap: {
291
- <R2>(middleware: Middleware<R2>): <T extends Toolkit>(toolkit: T) => WrapToolkit<T, R2>
292
- <T extends Toolkit, R2>(toolkit: T, middleware: Middleware<R2>): WrapToolkit<T, R2>
312
+ <R2, E2>(
313
+ middleware: Middleware<R2, E2>,
314
+ ): <T extends Toolkit>(toolkit: T) => WrapToolkit<T, R2, E2>
315
+ <T extends Toolkit, R2, E2>(toolkit: T, middleware: Middleware<R2, E2>): WrapToolkit<T, R2, E2>
293
316
  } = Function.dual(
294
317
  2,
295
- <T extends Toolkit, R2>(toolkit: T, middleware: Middleware<R2>): WrapToolkit<T, R2> =>
318
+ <T extends Toolkit, R2, E2>(toolkit: T, middleware: Middleware<R2, E2>): WrapToolkit<T, R2, E2> =>
296
319
  Record.map(toolkit, (tool) =>
297
320
  tool._tag === "LocalTool" ? { ...tool, run: middleware(tool.run, tool.name) } : tool,
298
- ) as WrapToolkit<T, R2>,
321
+ ) as WrapToolkit<T, R2, E2>,
322
+ )
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // describeFailures: map every local tool's error to a model-visible string,
326
+ // opting its failures into recovery. Group tools by recovery policy, wrap
327
+ // each group, then compose.
328
+ // ---------------------------------------------------------------------------
329
+
330
+ type DescribeTool<T extends AnyTool> =
331
+ T extends Tool<infer N, infer I, infer Ev, infer O, any, infer R>
332
+ ? Tool<N, I, Ev, O, string, R>
333
+ : T
334
+
335
+ type DescribeToolkit<T extends ToolMap> = {
336
+ readonly [K in keyof T]: T[K] extends AnyTool ? DescribeTool<T[K]> : T[K]
337
+ }
338
+
339
+ /**
340
+ * Map every local tool's error to a `string`, so `Toolkit.run` absorbs those
341
+ * failures into `ToolResult.Failure` the model reads and adapts to. Pairs
342
+ * with the domain `describe` helpers (`AiError.describe`,
343
+ * `BrowserError.describe`). Non-local kinds pass through.
344
+ *
345
+ * const kit = yield* Toolkit.compose(
346
+ * Toolkit.describeFailures(searchKit, AiError.describe),
347
+ * browserToolkit(session), // failures stay typed
348
+ * )
349
+ */
350
+ export const describeFailures: {
351
+ <E>(describe: (e: E) => string): <T extends Toolkit>(toolkit: T) => DescribeToolkit<T>
352
+ <T extends Toolkit>(toolkit: T, describe: (e: ToolkitE<T>) => string): DescribeToolkit<T>
353
+ } = Function.dual(
354
+ 2,
355
+ <T extends Toolkit>(toolkit: T, describe: (e: any) => string): DescribeToolkit<T> =>
356
+ Record.map(toolkit, (tool) =>
357
+ tool._tag === "LocalTool"
358
+ ? {
359
+ ...tool,
360
+ run: (input: unknown, emit: Emit<unknown>) =>
361
+ (tool as AnyLocalTool).run(input, emit).pipe(Effect.mapError(describe)),
362
+ }
363
+ : tool,
364
+ ) as DescribeToolkit<T>,
299
365
  )
300
366
 
301
367
  // ---------------------------------------------------------------------------
@@ -307,17 +373,26 @@ export type ExecuteOptions = {
307
373
  readonly concurrency?: number | "unbounded"
308
374
  }
309
375
 
310
- /** Execute every provided call. Approval/rejection policy belongs upstream. */
376
+ /**
377
+ * Execute every provided call. Approval/rejection policy belongs upstream.
378
+ *
379
+ * String and `ToolFailed` failures are absorbed into `ToolResult.Failure`
380
+ * (the model reads and adapts to them); every other tool error propagates
381
+ * typed on the stream, so `Exclude<ToolkitE<T>, string | ToolFailed>` is
382
+ * what a caller catches or lets end the run. If one call in a concurrent
383
+ * batch fails the stream, siblings may go unanswered: a loop that catches
384
+ * and continues must reconcile history first (`HistoryCheck.cancelAllPending`).
385
+ */
311
386
  export const run = <T extends Toolkit>(
312
387
  toolkit: T,
313
388
  calls: ReadonlyArray<ToolCall>,
314
389
  options?: ExecuteOptions,
315
- ): Stream.Stream<ToolEvent, never, ToolkitR<T>> =>
390
+ ): Stream.Stream<ToolEvent, Exclude<ToolkitE<T>, string | ToolFailed>, ToolkitR<T>> =>
316
391
  Stream.fromIterable(calls).pipe(
317
392
  Stream.flatMap((call) => runOne(toolkit, call), {
318
393
  concurrency: options?.concurrency ?? "unbounded",
319
394
  }),
320
- ) as Stream.Stream<ToolEvent, never, ToolkitR<T>>
395
+ ) as Stream.Stream<ToolEvent, Exclude<ToolkitE<T>, string | ToolFailed>, ToolkitR<T>>
321
396
 
322
397
  const okResult = (call: ToolCall, tool: string, value: unknown): ToolResult =>
323
398
  ToolResult.Ok({ call_id: call.call_id, tool, value })
@@ -325,8 +400,11 @@ const okResult = (call: ToolCall, tool: string, value: unknown): ToolResult =>
325
400
  const outputEvent = (result: ToolResult): Stream.Stream<ToolEvent, never, any> =>
326
401
  Stream.succeed(ToolEvent.Output({ result }))
327
402
 
328
- const runOne = (toolkit: ToolMap, call: ToolCall): Stream.Stream<ToolEvent, never, any> =>
329
- Match.value(toolkit[call.name]).pipe(
403
+ const runOne = (toolkit: ToolMap, call: ToolCall): Stream.Stream<ToolEvent, unknown, any> => {
404
+ // Own-property lookup: a hallucinated call named `constructor` / `toString`
405
+ // must not resolve to a prototype method.
406
+ const tool = Object.hasOwn(toolkit, call.name) ? toolkit[call.name] : undefined
407
+ return Match.value(tool).pipe(
330
408
  // No such tool. Graceful: emit a synthetic Failure so OTHER calls in this
331
409
  // turn still execute. LLMs hallucinate tool names; MCP tools come and go.
332
410
  Match.when(Match.undefined, () =>
@@ -338,23 +416,46 @@ const runOne = (toolkit: ToolMap, call: ToolCall): Stream.Stream<ToolEvent, neve
338
416
  // execution; reaching here means it didn't, so report it rather than crash.
339
417
  Match.orElse(() => outputEvent(nonLocalTool(call))),
340
418
  )
419
+ }
341
420
 
342
- const parseJsonUnknown = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
421
+ const describeIssues = (issues: ReadonlyArray<StandardSchemaV1.Issue>): string =>
422
+ issues
423
+ .map((issue) => {
424
+ const path = (issue.path ?? [])
425
+ .map((seg) => (typeof seg === "object" && seg !== null ? String(seg.key) : String(seg)))
426
+ .join(".")
427
+ return path === "" ? issue.message : `${path}: ${issue.message}`
428
+ })
429
+ .join("; ")
430
+ .slice(0, 500)
431
+
432
+ // A decode failure is the model's contract violation: report it as a result
433
+ // (never a stream failure) so the model can correct its next call.
434
+ const runTool = (tool: AnyLocalTool, call: ToolCall): Stream.Stream<ToolEvent, unknown, any> =>
435
+ Stream.unwrap(
436
+ decodeCallInput(tool, call).pipe(
437
+ Effect.map(
438
+ Match.type<DecodeResult<unknown>>().pipe(
439
+ Match.tag("parseError", () =>
440
+ outputEvent(validationError(call, "arguments are not valid JSON")),
441
+ ),
442
+ Match.tag("invalid", ({ issues }) =>
443
+ outputEvent(validationError(call, describeIssues(issues))),
444
+ ),
445
+ Match.tag("ok", ({ input }) => runValidated(tool, call, input)),
446
+ Match.exhaustive,
447
+ ),
448
+ ),
449
+ ),
450
+ )
343
451
 
344
- const runTool = (tool: AnyLocalTool, call: ToolCall): Stream.Stream<ToolEvent, never, any> =>
452
+ const runValidated = (
453
+ tool: AnyLocalTool,
454
+ call: ToolCall,
455
+ input: unknown,
456
+ ): Stream.Stream<ToolEvent, unknown, any> =>
345
457
  Stream.unwrap(
346
458
  Effect.gen(function* () {
347
- const parsed = yield* parseJsonUnknown(call.arguments).pipe(
348
- Effect.mapError(() => "json_parse_error" as const),
349
- )
350
- const validated = yield* Effect.tryPromise({
351
- try: () => Promise.resolve(tool.inputSchema["~standard"].validate(parsed)),
352
- catch: () => "validation_threw" as const,
353
- })
354
- if (validated.issues !== undefined) {
355
- return Stream.succeed<ToolEvent>(ToolEvent.Output({ result: validationError(call) }))
356
- }
357
-
358
459
  // Per-call queue: `emit` offers events as `run` produces them; the
359
460
  // progress stream drains them in real time; `Queue.end` (in the
360
461
  // `ensuring`) flushes pending events then signals a clean end, which
@@ -363,7 +464,7 @@ const runTool = (tool: AnyLocalTool, call: ToolCall): Stream.Stream<ToolEvent, n
363
464
  const queue = yield* Queue.make<unknown, Cause.Done>({ capacity: tool.emitBufferSize })
364
465
  const emit = (event: unknown) => Effect.asVoid(Queue.offer(queue, event))
365
466
  const fiber = yield* tool
366
- .run(validated.value, emit)
467
+ .run(input, emit)
367
468
  .pipe(Effect.ensuring(Queue.end(queue)), Effect.forkScoped)
368
469
 
369
470
  const progress = Stream.fromQueue(queue).pipe(
@@ -371,24 +472,38 @@ const runTool = (tool: AnyLocalTool, call: ToolCall): Stream.Stream<ToolEvent, n
371
472
  )
372
473
  const output = Stream.fromEffect(
373
474
  Fiber.join(fiber).pipe(
374
- Effect.map((value) => ToolEvent.Output({ result: okResult(call, tool.name, value) })),
375
- Effect.catchCause(() =>
376
- Effect.succeed(
377
- ToolEvent.Output({ result: executionError(call, "Tool execution failed") }),
378
- ),
379
- ),
475
+ Effect.matchCauseEffect({
476
+ onSuccess: (value: unknown) =>
477
+ Effect.succeed(ToolEvent.Output({ result: okResult(call, tool.name, value) })),
478
+ onFailure: (cause: Cause.Cause<unknown>) => routeCause(call, cause),
479
+ }),
380
480
  ),
381
481
  )
382
482
  return progress.pipe(Stream.concat(output))
383
483
  }),
384
- ).pipe(
385
- // Backstop for the input-parsing failures (`json_parse_error`,
386
- // `validation_threw`) and any defect before the run fiber is forked.
387
- Stream.catchCause(() =>
388
- Stream.succeed(ToolEvent.Output({ result: executionError(call, "Tool execution failed") })),
389
- ),
390
484
  )
391
485
 
486
+ // A clean single string / ToolFailed failure is absorbed as a model-visible
487
+ // result; interruption, defects, and typed non-sentinel errors propagate.
488
+ const routeCause = (
489
+ call: ToolCall,
490
+ cause: Cause.Cause<unknown>,
491
+ ): Effect.Effect<ToolEvent, unknown> => {
492
+ const only = cause.reasons.length === 1 ? cause.reasons[0] : undefined
493
+ if (only !== undefined && Cause.isFailReason(only)) {
494
+ const error = only.error
495
+ if (typeof error === "string") {
496
+ return Effect.succeed(ToolEvent.Output({ result: failed(call, "tool_failed", error) }))
497
+ }
498
+ if (error instanceof ToolFailed) {
499
+ return Effect.succeed(
500
+ ToolEvent.Output({ result: failed(call, error.kind ?? "tool_failed", error.message) }),
501
+ )
502
+ }
503
+ }
504
+ return Effect.failCause(cause)
505
+ }
506
+
392
507
  // ---------------------------------------------------------------------------
393
508
  // `continueWithResults` - bridge from a `Stream<ToolEvent>` to the loop's
394
509
  // emit shape. Drains the stream to the consumer in real-time, taps every
@@ -455,19 +570,17 @@ export const collectResults = <E, R>(
455
570
  export const continueWithResults: {
456
571
  <S>(
457
572
  build: (results: ReadonlyArray<ToolResult>) => S,
458
- ): <R>(
459
- stream: Stream.Stream<ToolEvent, never, R>,
460
- ) => Stream.Stream<Loop.Step<ToolEvent, S>, never, R>
461
- <S, R>(
462
- stream: Stream.Stream<ToolEvent, never, R>,
573
+ ): <E, R>(stream: Stream.Stream<ToolEvent, E, R>) => Stream.Stream<Loop.Step<ToolEvent, S>, E, R>
574
+ <S, E, R>(
575
+ stream: Stream.Stream<ToolEvent, E, R>,
463
576
  build: (results: ReadonlyArray<ToolResult>) => S,
464
- ): Stream.Stream<Loop.Step<ToolEvent, S>, never, R>
577
+ ): Stream.Stream<Loop.Step<ToolEvent, S>, E, R>
465
578
  } = Function.dual(
466
579
  2,
467
- <S, R>(
468
- stream: Stream.Stream<ToolEvent, never, R>,
580
+ <S, E, R>(
581
+ stream: Stream.Stream<ToolEvent, E, R>,
469
582
  build: (results: ReadonlyArray<ToolResult>) => S,
470
- ): Stream.Stream<Loop.Step<ToolEvent, S>, never, R> =>
583
+ ): Stream.Stream<Loop.Step<ToolEvent, S>, E, R> =>
471
584
  Stream.unwrap(
472
585
  Effect.gen(function* () {
473
586
  const ref = yield* Ref.make<ReadonlyArray<ToolResult>>([])
@@ -88,7 +88,7 @@ export class SttStreaming extends Context.Service<SttStreaming, void>()(
88
88
  export const transcribe = (
89
89
  request: CommonTranscribeRequest,
90
90
  ): Effect.Effect<TranscriptResult, AiError.AiError, Transcriber> =>
91
- Effect.flatMap(Transcriber.asEffect(), (t) => t.transcribe(request))
91
+ Effect.flatMap(Transcriber, (t) => t.transcribe(request))
92
92
 
93
93
  /**
94
94
  * Live transcription. Dual-arity: pipeable (data-last) and direct
@@ -122,8 +122,8 @@ export const streamTranscriptionFrom: {
122
122
  <E, R>(audioIn: Stream.Stream<Uint8Array, E, R>, request: CommonStreamTranscribeRequest) =>
123
123
  Stream.unwrap(
124
124
  Effect.gen(function* () {
125
- const t = yield* Transcriber.asEffect()
126
- yield* SttStreaming.asEffect()
125
+ const t = yield* Transcriber
126
+ yield* SttStreaming
127
127
  return t.streamTranscriptionFrom(audioIn, request)
128
128
  }),
129
129
  ),