@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,8 +1,9 @@
1
1
  import { Stream } from "effect"
2
+ import { Sse } from "effect/unstable/encoding"
2
3
 
3
4
  /**
4
5
  * One Server-Sent Event. Fields per the WHATWG spec:
5
- * - `event`: optional event name (default "message" on the wire)
6
+ * - `event`: optional event name (absent for the default "message")
6
7
  * - `data`: payload, with multiple `data:` lines joined by `\n`
7
8
  * - `id`: optional last-event id
8
9
  */
@@ -12,98 +13,44 @@ export type Event = {
12
13
  readonly id?: string
13
14
  }
14
15
 
15
- // ---------------------------------------------------------------------------
16
- // Generic stream helpers (kept module-local for now; promote to a shared
17
- // Stream module once a third caller appears).
18
- // ---------------------------------------------------------------------------
16
+ const toEvent = (wire: Sse.Event): Event => ({
17
+ data: wire.data,
18
+ ...(wire.event !== "message" && { event: wire.event }),
19
+ ...(wire.id !== undefined && { id: wire.id }),
20
+ })
19
21
 
20
- /** Decode `Uint8Array` chunks as UTF-8, handling multi-byte boundaries. */
21
- const decodeText = <E, R>(self: Stream.Stream<Uint8Array, E, R>): Stream.Stream<string, E, R> =>
22
- self.pipe(
23
- Stream.mapAccum(
24
- (): TextDecoder => new TextDecoder("utf-8"),
25
- (decoder, chunk: Uint8Array) => [decoder, [decoder.decode(chunk, { stream: true })]] as const,
26
- {
27
- onHalt: (decoder: TextDecoder) => {
28
- const tail = decoder.decode()
29
- return tail.length > 0 ? [tail] : []
30
- },
31
- },
32
- ),
33
- )
34
-
35
- /** Split a text stream on a separator, buffering across chunk boundaries. */
36
- const splitOn =
37
- (separator: string) =>
38
- <E, R>(self: Stream.Stream<string, E, R>): Stream.Stream<string, E, R> =>
39
- self.pipe(
40
- Stream.mapAccum(
41
- (): string => "",
42
- (buffer, chunk: string) => {
43
- const parts = (buffer + chunk).split(separator)
44
- const tail = parts[parts.length - 1] ?? ""
45
- return [tail, parts.slice(0, -1)] as const
46
- },
47
- { onHalt: (tail: string) => (tail.length > 0 ? [tail] : []) },
48
- ),
49
- )
50
-
51
- // ---------------------------------------------------------------------------
52
- // Parser
53
- // ---------------------------------------------------------------------------
54
-
55
- const parseField = (line: string): readonly [string, string] => {
56
- const colon = line.indexOf(":")
57
- if (colon < 0) return [line, ""]
58
- const value = line.slice(colon + 1)
59
- return [line.slice(0, colon), value.startsWith(" ") ? value.slice(1) : value]
22
+ type ParserState = {
23
+ readonly parser: Sse.Parser
24
+ readonly out: Array<Event>
60
25
  }
61
26
 
62
- const parseBlock = (block: string): Event | null => {
63
- const lines = block.split("\n").filter((l) => l.length > 0 && !l.startsWith(":"))
64
- if (lines.length === 0) return null
65
-
66
- const fields = lines.map(parseField)
67
- const dataLines = fields.filter(([f]) => f === "data").map(([, v]) => v)
68
- const event = fields.find(([f]) => f === "event")?.[1]
69
- const id = fields.find(([f]) => f === "id")?.[1]
70
-
71
- const out: { event?: string; data: string; id?: string } = {
72
- data: dataLines.join("\n"),
73
- }
74
- if (event !== undefined) out.event = event
75
- if (id !== undefined) out.id = id
76
- return out as Event
27
+ const makeParserState = (): ParserState => {
28
+ const out: Array<Event> = []
29
+ const parser = Sse.makeParser((event) => {
30
+ // `retry:` reconnection hints have no representation in `Event`
31
+ if (event._tag === "Event") out.push(toEvent(event))
32
+ })
33
+ return { parser, out }
77
34
  }
78
35
 
79
- // ---------------------------------------------------------------------------
80
- // Public API
81
- // ---------------------------------------------------------------------------
82
-
83
36
  /**
84
37
  * Decode a `Stream<Uint8Array>` (e.g. an HTTP response body) into a
85
- * `Stream<SSE.Event>`. Handles partial UTF-8 sequences, CRLF/LF line
86
- * endings, and events split across chunk boundaries.
38
+ * `Stream<SSE.Event>`, using the spec parser from `effect/unstable/encoding`.
39
+ * An event is dispatched only once its terminating blank line arrives, so a
40
+ * partial event at stream end (clean or failed) is discarded per the spec
41
+ * rather than surfaced as a truncated frame.
87
42
  */
88
43
  export const fromBytes = <E, R>(
89
44
  self: Stream.Stream<Uint8Array, E, R>,
90
45
  ): Stream.Stream<Event, E, R> =>
91
46
  self.pipe(
92
- decodeText,
93
- Stream.map((s) => s.replace(/\r/g, "")), // SSE allows CRLF; normalize to LF
94
- splitOn("\n\n"),
95
- Stream.map(parseBlock),
96
- Stream.filter((ev): ev is Event => ev !== null),
47
+ Stream.decodeText,
48
+ Stream.mapAccum(makeParserState, (state, text: string) => {
49
+ state.parser.feed(text)
50
+ return [state, state.out.splice(0)] as const
51
+ }),
97
52
  )
98
53
 
99
- const eventToString = (ev: Event): string => {
100
- const parts: string[] = []
101
- if (ev.event !== undefined) parts.push(`event: ${ev.event}`)
102
- if (ev.id !== undefined) parts.push(`id: ${ev.id}`)
103
- for (const line of ev.data.split("\n")) parts.push(`data: ${line}`)
104
- return parts.join("\n") + "\n\n"
105
- }
106
-
107
54
  const encoder = new TextEncoder()
108
55
 
109
56
  /**
@@ -111,4 +58,13 @@ const encoder = new TextEncoder()
111
58
  * HTTP response with `Content-Type: text/event-stream`.
112
59
  */
113
60
  export const toBytes = <E, R>(self: Stream.Stream<Event, E, R>): Stream.Stream<Uint8Array, E, R> =>
114
- Stream.map(self, (ev) => encoder.encode(eventToString(ev)))
61
+ Stream.map(self, (ev) =>
62
+ encoder.encode(
63
+ Sse.encoder.write({
64
+ _tag: "Event",
65
+ event: ev.event ?? "message",
66
+ id: ev.id,
67
+ data: ev.data,
68
+ }),
69
+ ),
70
+ )
@@ -15,7 +15,13 @@ import { describe, expect, it } from "vitest"
15
15
  import * as Items from "../domain/Items.js"
16
16
  import { findUnansweredCalls, cancelAllPending, isReconciled } from "./HistoryCheck.js"
17
17
  import { type ToolResult, isFailure, isOk, toToolCallOutput } from "./ToolResult.js"
18
- import { type ApprovalMapEntry, type ApprovalDecision, fromMap, fromQueue } from "./Approval.js"
18
+ import {
19
+ type ApprovalMapEntry,
20
+ type ApprovalDecision,
21
+ type Verdict,
22
+ fromMap,
23
+ fromQueue,
24
+ } from "./Approval.js"
19
25
  import { fromEffectSchema, make as makeTool } from "./Tool.js"
20
26
  import { make as makeToolkit, run } from "./Toolkit.js"
21
27
  import { ToolEvent, isApprovalRequested, isProgress, isOutput } from "./ToolEvent.js"
@@ -249,6 +255,50 @@ describe("fromQueue + run", () => {
249
255
  reason: "too risky",
250
256
  })
251
257
  })
258
+
259
+ it("router retires per round, so sequential rounds over one queue both resolve", async () => {
260
+ // A verdict-per-round over one shared queue and scope. The old
261
+ // never-terminating router would still be alive for round 1 and steal
262
+ // round 2's verdict (not in its map), hanging round 2.
263
+ const decisions = await Effect.runPromise(
264
+ Effect.scoped(
265
+ Effect.gen(function* () {
266
+ const verdicts = yield* Queue.unbounded<Verdict>()
267
+ const round = (id: string) => [fc(id, "delete_database", { name: "prod" })]
268
+
269
+ const r1 = yield* fromQueue(isSensitive, verdicts)(round("c1"))
270
+ yield* Queue.offer(verdicts, { call_id: "c1", decision: "approve" })
271
+ const d1 = Array.from(yield* Stream.runCollect(r1.decisions))
272
+
273
+ const r2 = yield* fromQueue(isSensitive, verdicts)(round("c2"))
274
+ yield* Queue.offer(verdicts, { call_id: "c2", decision: "deny", reason: "no" })
275
+ const d2 = Array.from(yield* Stream.runCollect(r2.decisions))
276
+
277
+ return { d1, d2 }
278
+ }),
279
+ ),
280
+ )
281
+ expect(decisions.d1[0]).toMatchObject({ _tag: "Approved" })
282
+ expect(decisions.d2[0]).toMatchObject({ _tag: "Rejected", result: { kind: "denied" } })
283
+ })
284
+
285
+ it("timeout resolves an unanswered gated call as cancelled", async () => {
286
+ const decisions = await Effect.runPromise(
287
+ Effect.scoped(
288
+ Effect.gen(function* () {
289
+ const verdicts = yield* Queue.unbounded<Verdict>()
290
+ const { decisions } = yield* fromQueue(isSensitive, verdicts, { timeout: "10 millis" })([
291
+ fc("c1", "delete_database", { name: "prod" }),
292
+ ])
293
+ return Array.from(yield* Stream.runCollect(decisions))
294
+ }),
295
+ ),
296
+ )
297
+ expect(decisions[0]).toMatchObject({
298
+ _tag: "Rejected",
299
+ result: { _tag: "Failure", kind: "cancelled" },
300
+ })
301
+ })
252
302
  })
253
303
 
254
304
  // ---------------------------------------------------------------------------
@@ -313,7 +363,7 @@ describe("toToolCallOutput", () => {
313
363
  expect(JSON.parse(out.output)).toEqual({ count: 3 })
314
364
  })
315
365
 
316
- it("round-trips a Failure result with reason", () => {
366
+ it("renders a Failure with reason as a nested error object", () => {
317
367
  const r: ToolResult = {
318
368
  _tag: "Failure",
319
369
  call_id: "c2",
@@ -322,10 +372,10 @@ describe("toToolCallOutput", () => {
322
372
  reason: "spam concern",
323
373
  }
324
374
  const out = toToolCallOutput(r)
325
- expect(JSON.parse(out.output)).toEqual({ kind: "denied", reason: "spam concern" })
375
+ expect(JSON.parse(out.output)).toEqual({ error: { kind: "denied", message: "spam concern" } })
326
376
  })
327
377
 
328
- it("round-trips a Failure result without reason (omits the field)", () => {
378
+ it("renders a Failure without reason (omits message)", () => {
329
379
  const r: ToolResult = {
330
380
  _tag: "Failure",
331
381
  call_id: "c3",
@@ -333,7 +383,7 @@ describe("toToolCallOutput", () => {
333
383
  kind: "cancelled",
334
384
  }
335
385
  const out = toToolCallOutput(r)
336
- expect(JSON.parse(out.output)).toEqual({ kind: "cancelled" })
386
+ expect(JSON.parse(out.output)).toEqual({ error: { kind: "cancelled" } })
337
387
  })
338
388
  })
339
389
 
@@ -5,7 +5,7 @@
5
5
  * results must be returned to the model. Tool execution stays explicit at
6
6
  * the recipe boundary via `Toolkit.run`.
7
7
  */
8
- import { Data, Deferred, Effect, Queue, Scope, Stream } from "effect"
8
+ import { Data, Deferred, type Duration, Effect, Queue, Ref, Scope, Stream } from "effect"
9
9
  import type { ToolCall } from "../domain/Items.js"
10
10
  import { type ToolResult, cancelled, denied } from "./ToolResult.js"
11
11
  import { ToolEvent } from "./ToolEvent.js"
@@ -53,13 +53,30 @@ export type Verdict = {
53
53
  readonly reason?: string
54
54
  }
55
55
 
56
+ export type FromQueueOptions = {
57
+ /**
58
+ * Resolve any still-pending gated call as `cancelled` once this elapses,
59
+ * so a lost or never-sent verdict cannot hang the decisions stream
60
+ * forever. Omit to wait indefinitely.
61
+ */
62
+ readonly timeout?: Duration.Input
63
+ }
64
+
56
65
  /**
57
66
  * Queue-backed approval planner. Safe calls are returned immediately in
58
67
  * `approved`; gated calls emit `ApprovalRequested` events and later produce
59
68
  * one `ApprovalDecision` when their matching verdict arrives.
69
+ *
70
+ * The verdict queue is a single-consumer transport: run one approval round
71
+ * at a time (the router retires once every gated call of the round is
72
+ * resolved). For overlapping rounds, use one queue per round.
60
73
  */
61
74
  export const fromQueue =
62
- (predicate: (call: ToolCall) => boolean, verdicts: Queue.Dequeue<Verdict>) =>
75
+ (
76
+ predicate: (call: ToolCall) => boolean,
77
+ verdicts: Queue.Dequeue<Verdict>,
78
+ options?: FromQueueOptions,
79
+ ) =>
63
80
  (
64
81
  calls: ReadonlyArray<ToolCall>,
65
82
  ): Effect.Effect<
@@ -76,37 +93,65 @@ export const fromQueue =
76
93
  const approved = calls.filter((call) => !predicate(call))
77
94
 
78
95
  const entries = yield* Effect.forEach(gated, (call) =>
79
- Deferred.make<Verdict>().pipe(Effect.map((d) => [call.call_id, d] as const)),
96
+ Deferred.make<ApprovalDecision>().pipe(Effect.map((d) => [call.call_id, d] as const)),
80
97
  )
81
- const deferreds: ReadonlyMap<string, Deferred.Deferred<Verdict>> = new Map(entries)
82
-
83
- // Router is forked into the surrounding Scope so it lives as long
84
- // as the consumer is pulling events. Recipes typically supply the
85
- // scope by wrapping the events construction in `Stream.unwrap`.
86
- yield* Effect.forkScoped(
87
- Effect.forever(
88
- Effect.gen(function* () {
89
- const v = yield* Queue.take(verdicts)
90
- const d = deferreds.get(v.call_id)
91
- if (d !== undefined) yield* Deferred.succeed(d, v)
92
- }),
98
+ const deferreds: ReadonlyMap<string, Deferred.Deferred<ApprovalDecision>> = new Map(entries)
99
+ const callById: ReadonlyMap<string, ToolCall> = new Map(gated.map((c) => [c.call_id, c]))
100
+ const outstanding = yield* Ref.make(gated.length)
101
+
102
+ // Complete a deferred once; decrement only on the winning transition so
103
+ // a late verdict for an already-resolved call is a no-op.
104
+ const resolve = (call: ToolCall, decision: ApprovalDecision) =>
105
+ Deferred.succeed(deferreds.get(call.call_id)!, decision).pipe(
106
+ Effect.flatMap((won) => (won ? Ref.update(outstanding, (n) => n - 1) : Effect.void)),
107
+ )
108
+
109
+ // Router retires once every gated call is resolved (by verdict or
110
+ // timeout), so sequential rounds over one queue do not accumulate
111
+ // racing routers. Recursive + suspended: stack-safe, no imperative loop.
112
+ const route: Effect.Effect<void> = Effect.suspend(() =>
113
+ Ref.get(outstanding).pipe(
114
+ Effect.flatMap((n) =>
115
+ n <= 0
116
+ ? Effect.void
117
+ : Queue.take(verdicts).pipe(
118
+ Effect.flatMap((v) => {
119
+ const call = callById.get(v.call_id)
120
+ return (
121
+ call === undefined
122
+ ? Effect.void
123
+ : resolve(
124
+ call,
125
+ v.decision === "approve"
126
+ ? approve(call)
127
+ : reject(denied(call, v.reason)),
128
+ )
129
+ ).pipe(Effect.andThen(route))
130
+ }),
131
+ ),
132
+ ),
93
133
  ),
94
134
  )
95
135
 
96
- const decisions = Stream.fromIterable(gated).pipe(
97
- Stream.flatMap(
98
- (call) => {
99
- const d = deferreds.get(call.call_id)!
100
- return Stream.fromEffect(
101
- Deferred.await(d).pipe(
102
- Effect.map((v) =>
103
- v.decision === "approve" ? approve(call) : reject(denied(call, v.reason)),
136
+ if (gated.length > 0) {
137
+ yield* Effect.forkScoped(route)
138
+ if (options?.timeout !== undefined) {
139
+ yield* Effect.forkScoped(
140
+ Effect.sleep(options.timeout).pipe(
141
+ Effect.andThen(
142
+ Effect.forEach(gated, (call) =>
143
+ resolve(call, reject(cancelled(call, "approval timed out"))),
104
144
  ),
105
145
  ),
106
- )
107
- },
108
- { concurrency: "unbounded" },
109
- ),
146
+ ),
147
+ )
148
+ }
149
+ }
150
+
151
+ const decisions = Stream.fromIterable(gated).pipe(
152
+ Stream.flatMap((call) => Stream.fromEffect(Deferred.await(deferreds.get(call.call_id)!)), {
153
+ concurrency: "unbounded",
154
+ }),
110
155
  )
111
156
 
112
157
  const approvalRequests = Stream.fromIterable<ToolEvent>(gated.map(approvalRequested))
@@ -1,5 +1,5 @@
1
1
  import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
2
- import { Effect } from "effect"
2
+ import { Context, Effect, Schema } from "effect"
3
3
  import { describe, expect, expectTypeOf, it } from "vitest"
4
4
  import type * as Items from "../domain/Items.js"
5
5
  import * as Tool from "./Tool.js"
@@ -156,3 +156,153 @@ describe("Tool.decodeArgs", () => {
156
156
  expect(exit._tag).toBe("Failure")
157
157
  })
158
158
  })
159
+
160
+ describe("Effect Schema inputSchema", () => {
161
+ const Recipient = Schema.Struct({ to: Schema.String })
162
+
163
+ const sendEmail = Tool.make({
164
+ name: "send_email",
165
+ description: "Send an email to a single recipient.",
166
+ inputSchema: Recipient,
167
+ run: ({ to }) => Effect.succeed(`queued: ${to}`),
168
+ })
169
+
170
+ const callWith = (args: string): Items.ToolCall => ({
171
+ type: "function_call",
172
+ call_id: "call_1",
173
+ name: "send_email",
174
+ arguments: args,
175
+ })
176
+
177
+ it("runs with the input inferred from the schema's Type", async () => {
178
+ const result = await Effect.runPromise(sendEmail.run({ to: "x@y.z" }, () => Effect.void))
179
+ expect(result).toBe("queued: x@y.z")
180
+ })
181
+
182
+ it("type: Input is the schema's Type", () => {
183
+ type InputOf<T> = T extends Tool.Tool<string, infer I, any, any, any> ? I : never
184
+ expectTypeOf<InputOf<typeof sendEmail>>().toEqualTypeOf<{ readonly to: string }>()
185
+ })
186
+
187
+ it("type: E and R are inferred from run through the Effect Schema overload", () => {
188
+ class Mailer extends Context.Service<
189
+ Mailer,
190
+ { readonly send: (to: string) => Effect.Effect<string> }
191
+ >()("test/Mailer") {}
192
+
193
+ const tool = Tool.make({
194
+ name: "send_email",
195
+ description: "send",
196
+ inputSchema: Recipient,
197
+ run: ({ to }) =>
198
+ Effect.gen(function* () {
199
+ const mailer = yield* Mailer
200
+ if (to === "") return yield* Effect.fail("empty" as const)
201
+ return yield* mailer.send(to)
202
+ }),
203
+ })
204
+
205
+ expectTypeOf<Tool.ToolE<typeof tool>>().toEqualTypeOf<"empty">()
206
+ expectTypeOf<Tool.ToolR<typeof tool>>().toEqualTypeOf<Mailer>()
207
+ })
208
+
209
+ it("decodes valid JSON arguments to the typed input", async () => {
210
+ const input = await Effect.runPromise(Tool.decodeArgs(sendEmail, callWith('{"to":"x@y.z"}')))
211
+ expect(input).toEqual({ to: "x@y.z" })
212
+ expectTypeOf(input).toEqualTypeOf<{ readonly to: string }>()
213
+ })
214
+
215
+ it("fails with ToolValidationError when the arguments don't satisfy the schema", async () => {
216
+ const exit = await Effect.runPromiseExit(Tool.decodeArgs(sendEmail, callWith('{"to":42}')))
217
+ expect(exit._tag).toBe("Failure")
218
+ })
219
+
220
+ it("renders a JSON Schema descriptor from the wrapped schema", () => {
221
+ const [descriptor] = Tool.toDescriptors([sendEmail])
222
+ expect(descriptor?.name).toBe("send_email")
223
+ expect(descriptor?.inputSchema).toMatchObject({
224
+ type: "object",
225
+ properties: { to: { type: "string" } },
226
+ required: ["to"],
227
+ })
228
+ })
229
+
230
+ it("accepts an already-wrapped fromEffectSchema value unchanged", async () => {
231
+ const wrapped = Tool.make({
232
+ name: "send_email",
233
+ description: "Send an email to a single recipient.",
234
+ inputSchema: Tool.fromEffectSchema(Recipient),
235
+ run: ({ to }) => Effect.succeed(`queued: ${to}`),
236
+ })
237
+ expect(Tool.toDescriptors([wrapped])).toEqual(Tool.toDescriptors([sendEmail]))
238
+ const input = await Effect.runPromise(Tool.decodeArgs(wrapped, callWith('{"to":"x@y.z"}')))
239
+ expectTypeOf(input).toEqualTypeOf<{ readonly to: string }>()
240
+ })
241
+
242
+ it("signal: decode-only kinds accept an Effect Schema directly", async () => {
243
+ const escalate = Tool.signal({
244
+ name: "escalate",
245
+ description: "Escalate to a human.",
246
+ inputSchema: Schema.Struct({ reason: Schema.String }),
247
+ })
248
+ const input = await Effect.runPromise(
249
+ Tool.decodeArgs(escalate, {
250
+ type: "function_call",
251
+ call_id: "call_2",
252
+ name: "escalate",
253
+ arguments: '{"reason":"stuck"}',
254
+ }),
255
+ )
256
+ expect(input).toEqual({ reason: "stuck" })
257
+ expectTypeOf(input).toEqualTypeOf<{ readonly reason: string }>()
258
+ })
259
+ })
260
+
261
+ describe("provider-hosted tools", () => {
262
+ const localTool = Tool.make({
263
+ name: "send_email",
264
+ description: "Send an email.",
265
+ inputSchema: Tool.fromEffectSchema(Schema.Struct({ to: Schema.String })),
266
+ run: ({ to }) => Effect.succeed(`sent: ${to}`),
267
+ })
268
+ const signalTool = Tool.signal({
269
+ name: "escalate",
270
+ description: "Escalate to a human.",
271
+ inputSchema: Schema.Struct({ reason: Schema.String }),
272
+ })
273
+ const hostedTool = Tool.provider({
274
+ name: "web_search",
275
+ description: "Search the web.",
276
+ inputSchema: Tool.noInput,
277
+ provider: "gemini",
278
+ config: { kind: "google_search" },
279
+ })
280
+ const toolkit = { send_email: localTool, escalate: signalTool, web_search: hostedTool }
281
+
282
+ it("descriptorsOf excludes provider-hosted tools", () => {
283
+ const descriptors = Tool.descriptorsOf(toolkit)
284
+ expect(descriptors.map((d) => d.name)).toEqual(["send_email", "escalate"])
285
+ })
286
+
287
+ it("toDescriptors drops a ProviderTool from a mixed array", () => {
288
+ expect(Tool.toDescriptors([localTool, hostedTool]).map((d) => d.name)).toEqual(["send_email"])
289
+ })
290
+
291
+ it("providerToolsOf returns only the provider-hosted tools", () => {
292
+ const provider = Tool.providerToolsOf(toolkit)
293
+ expect(provider).toHaveLength(1)
294
+ expect(provider[0]?.name).toBe("web_search")
295
+ expect(provider[0]?.provider).toBe("gemini")
296
+ expect(provider[0]?.config).toEqual({ kind: "google_search" })
297
+ })
298
+
299
+ it("providerToolsOf on an absent toolkit is empty", () => {
300
+ expect(Tool.providerToolsOf(undefined)).toEqual([])
301
+ })
302
+
303
+ it("isProviderTool narrows the tool kind", () => {
304
+ expect(Tool.isProviderTool(hostedTool)).toBe(true)
305
+ expect(Tool.isProviderTool(localTool)).toBe(false)
306
+ expect(Tool.isProviderTool(signalTool)).toBe(false)
307
+ })
308
+ })