@effect-uai/core 0.10.0 → 0.12.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 (146) hide show
  1. package/dist/{Image-BZmKfIdq.d.mts → Image-C4vuBmu0.d.mts} +11 -11
  2. package/dist/{Image-BZmKfIdq.d.mts.map → Image-C4vuBmu0.d.mts.map} +1 -1
  3. package/dist/{Items-BnIduEV2.d.mts → Items-BKQgqJ8A.d.mts} +136 -97
  4. package/dist/Items-BKQgqJ8A.d.mts.map +1 -0
  5. package/dist/{Tool-2lC_XMth.d.mts → Tool-u5bag8SO.d.mts} +42 -9
  6. package/dist/Tool-u5bag8SO.d.mts.map +1 -0
  7. package/dist/{ToolEvent-cWZ2BnbS.d.mts → ToolEvent-BBfOdhF_.d.mts} +2 -2
  8. package/dist/{ToolEvent-cWZ2BnbS.d.mts.map → ToolEvent-BBfOdhF_.d.mts.map} +1 -1
  9. package/dist/{ToolResult-CL4hK_9z.d.mts → ToolResult-CRpmkBXR.d.mts} +2 -2
  10. package/dist/{ToolResult-CL4hK_9z.d.mts.map → ToolResult-CRpmkBXR.d.mts.map} +1 -1
  11. package/dist/{Toolkit-Dr9l_hNy.d.mts → Toolkit-D4mQvhjq.d.mts} +6 -6
  12. package/dist/Toolkit-D4mQvhjq.d.mts.map +1 -0
  13. package/dist/browser/Browser.mjs +4 -4
  14. package/dist/browser/Browser.mjs.map +1 -1
  15. package/dist/browser/BrowserTool.d.mts +34 -33
  16. package/dist/browser/BrowserTool.d.mts.map +1 -1
  17. package/dist/{dist-DWgRbDN3.mjs → dist-C0m0QnUP.mjs} +71 -30
  18. package/dist/dist-C0m0QnUP.mjs.map +1 -0
  19. package/dist/{dist-B04dlFHg.mjs → dist-CcRV3-A5.mjs} +8 -8
  20. package/dist/{dist-B04dlFHg.mjs.map → dist-CcRV3-A5.mjs.map} +1 -1
  21. package/dist/domain/Citation.d.mts +60 -0
  22. package/dist/domain/Citation.d.mts.map +1 -0
  23. package/dist/domain/Citation.mjs +7 -0
  24. package/dist/domain/Citation.mjs.map +1 -0
  25. package/dist/domain/Image.d.mts +1 -1
  26. package/dist/domain/Items.d.mts +1 -1
  27. package/dist/domain/Items.mjs +28 -4
  28. package/dist/domain/Items.mjs.map +1 -1
  29. package/dist/domain/Research.d.mts +37 -0
  30. package/dist/domain/Research.d.mts.map +1 -0
  31. package/dist/domain/Research.mjs +7 -0
  32. package/dist/domain/Research.mjs.map +1 -0
  33. package/dist/domain/Turn.d.mts +92 -9
  34. package/dist/domain/Turn.d.mts.map +1 -1
  35. package/dist/domain/Turn.mjs +8 -1
  36. package/dist/domain/Turn.mjs.map +1 -1
  37. package/dist/domain/Turn.test.mjs +1 -1
  38. package/dist/embedding-model/Embedding.d.mts +1 -1
  39. package/dist/embedding-model/EmbeddingModel.mjs +2 -2
  40. package/dist/embedding-model/EmbeddingModel.mjs.map +1 -1
  41. package/dist/embedding-model/EmbeddingModel.test.mjs +1 -1
  42. package/dist/index.d.mts +12 -8
  43. package/dist/index.mjs +5 -1
  44. package/dist/job/Job.d.mts +80 -0
  45. package/dist/job/Job.d.mts.map +1 -0
  46. package/dist/job/Job.mjs +49 -0
  47. package/dist/job/Job.mjs.map +1 -0
  48. package/dist/language-model/LanguageModel.d.mts +2 -2
  49. package/dist/language-model/LanguageModel.mjs +2 -2
  50. package/dist/language-model/LanguageModel.mjs.map +1 -1
  51. package/dist/language-model/LanguageModel.test.mjs +1 -1
  52. package/dist/loop/Loop.test.mjs +1 -1
  53. package/dist/music-generator/MusicGenerator.mjs +4 -4
  54. package/dist/music-generator/MusicGenerator.mjs.map +1 -1
  55. package/dist/music-generator/MusicGenerator.test.mjs +1 -1
  56. package/dist/observability/Metrics.d.mts +13 -5
  57. package/dist/observability/Metrics.d.mts.map +1 -1
  58. package/dist/observability/Metrics.mjs +30 -7
  59. package/dist/observability/Metrics.mjs.map +1 -1
  60. package/dist/observability/Metrics.test.mjs +44 -2
  61. package/dist/observability/Metrics.test.mjs.map +1 -1
  62. package/dist/observability/Telemetry.test.mjs +2 -2
  63. package/dist/research/DeepResearch.d.mts +71 -0
  64. package/dist/research/DeepResearch.d.mts.map +1 -0
  65. package/dist/research/DeepResearch.mjs +61 -0
  66. package/dist/research/DeepResearch.mjs.map +1 -0
  67. package/dist/retry/Retry.d.mts +3 -3
  68. package/dist/retry/Retry.d.mts.map +1 -1
  69. package/dist/retry/Retry.mjs +30 -1
  70. package/dist/retry/Retry.mjs.map +1 -1
  71. package/dist/retry/Retry.test.mjs +1 -1
  72. package/dist/sandbox/Sandbox.mjs +18 -18
  73. package/dist/sandbox/Sandbox.mjs.map +1 -1
  74. package/dist/speech-synthesizer/SpeechSynthesizer.mjs +8 -8
  75. package/dist/speech-synthesizer/SpeechSynthesizer.mjs.map +1 -1
  76. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +1 -1
  77. package/dist/streaming/JSONL.d.mts +3 -1
  78. package/dist/streaming/JSONL.d.mts.map +1 -1
  79. package/dist/streaming/JSONL.mjs +5 -11
  80. package/dist/streaming/JSONL.mjs.map +1 -1
  81. package/dist/streaming/JSONL.test.mjs +10 -1
  82. package/dist/streaming/JSONL.test.mjs.map +1 -1
  83. package/dist/streaming/SSE.d.mts +5 -3
  84. package/dist/streaming/SSE.d.mts.map +1 -1
  85. package/dist/streaming/SSE.mjs +28 -38
  86. package/dist/streaming/SSE.mjs.map +1 -1
  87. package/dist/streaming/SSE.test.mjs +54 -4
  88. package/dist/streaming/SSE.test.mjs.map +1 -1
  89. package/dist/structured-format/StructuredFormat.test.mjs +1 -1
  90. package/dist/testing/MockProvider.d.mts +1 -1
  91. package/dist/testing/MockSandbox.d.mts +1 -1
  92. package/dist/tool/Approval.d.mts +3 -3
  93. package/dist/tool/Approval.test.mjs +1 -1
  94. package/dist/tool/HistoryCheck.d.mts +2 -2
  95. package/dist/tool/Tool.d.mts +2 -2
  96. package/dist/tool/Tool.mjs +27 -7
  97. package/dist/tool/Tool.mjs.map +1 -1
  98. package/dist/tool/Tool.test.mjs +127 -3
  99. package/dist/tool/Tool.test.mjs.map +1 -1
  100. package/dist/tool/ToolEvent.d.mts +1 -1
  101. package/dist/tool/ToolResult.d.mts +1 -1
  102. package/dist/tool/Toolkit.d.mts +1 -1
  103. package/dist/tool/Toolkit.mjs.map +1 -1
  104. package/dist/tool/Toolkit.test.mjs +46 -2
  105. package/dist/tool/Toolkit.test.mjs.map +1 -1
  106. package/dist/transcriber/Transcriber.mjs +3 -3
  107. package/dist/transcriber/Transcriber.mjs.map +1 -1
  108. package/dist/transcriber/Transcriber.test.mjs +1 -1
  109. package/dist/web-read/WebRead.mjs +1 -1
  110. package/dist/web-read/WebRead.mjs.map +1 -1
  111. package/dist/web-read/WebReadTool.d.mts +1 -1
  112. package/dist/web-search/WebSearch.mjs +1 -1
  113. package/dist/web-search/WebSearch.mjs.map +1 -1
  114. package/dist/web-search/WebSearchTool.d.mts +1 -1
  115. package/package.json +19 -3
  116. package/src/browser/Browser.ts +4 -4
  117. package/src/domain/Citation.ts +64 -0
  118. package/src/domain/Items.ts +32 -4
  119. package/src/domain/Research.ts +42 -0
  120. package/src/domain/Turn.ts +26 -0
  121. package/src/embedding-model/EmbeddingModel.ts +2 -2
  122. package/src/index.ts +4 -0
  123. package/src/job/Job.ts +125 -0
  124. package/src/language-model/LanguageModel.ts +2 -2
  125. package/src/music-generator/MusicGenerator.ts +4 -4
  126. package/src/observability/Metrics.test.ts +65 -0
  127. package/src/observability/Metrics.ts +53 -11
  128. package/src/research/DeepResearch.ts +137 -0
  129. package/src/retry/Retry.ts +44 -3
  130. package/src/sandbox/Sandbox.ts +18 -18
  131. package/src/speech-synthesizer/SpeechSynthesizer.ts +8 -8
  132. package/src/streaming/JSONL.test.ts +19 -0
  133. package/src/streaming/JSONL.ts +6 -38
  134. package/src/streaming/SSE.test.ts +86 -3
  135. package/src/streaming/SSE.ts +36 -80
  136. package/src/tool/Tool.test.ts +151 -1
  137. package/src/tool/Tool.ts +120 -22
  138. package/src/tool/Toolkit.test.ts +60 -0
  139. package/src/tool/Toolkit.ts +2 -2
  140. package/src/transcriber/Transcriber.ts +3 -3
  141. package/src/web-read/WebRead.ts +1 -1
  142. package/src/web-search/WebSearch.ts +1 -1
  143. package/dist/Items-BnIduEV2.d.mts.map +0 -1
  144. package/dist/Tool-2lC_XMth.d.mts.map +0 -1
  145. package/dist/Toolkit-Dr9l_hNy.d.mts.map +0 -1
  146. package/dist/dist-DWgRbDN3.mjs.map +0 -1
@@ -1,4 +1,4 @@
1
- import { Effect, Stream } from "effect"
1
+ import { Cause, Deferred, Effect, Exit, Fiber, Result, Stream } from "effect"
2
2
  import { describe, expect, it } from "vitest"
3
3
  import * as SSE from "./SSE.js"
4
4
 
@@ -8,6 +8,21 @@ const bytesOf = (...chunks: ReadonlyArray<string>) =>
8
8
 
9
9
  const collect = <A, E>(s: Stream.Stream<A, E>) => Effect.runPromise(Stream.runCollect(s))
10
10
 
11
+ const runUntilEnd = <A, E>(s: Stream.Stream<A, E>) =>
12
+ Effect.runPromise(
13
+ s.pipe(
14
+ Stream.result,
15
+ Stream.runCollect,
16
+ Effect.map((results) => ({
17
+ seen: results.filter(Result.isSuccess).map((r) => r.success),
18
+ failures: results.filter(Result.isFailure).map((r) => r.failure),
19
+ })),
20
+ ),
21
+ )
22
+
23
+ const failuresOf = (exit: Exit.Exit<unknown, unknown>): ReadonlyArray<unknown> =>
24
+ Exit.isFailure(exit) ? exit.cause.reasons.filter(Cause.isFailReason).map((r) => r.error) : []
25
+
11
26
  describe("SSE.fromBytes", () => {
12
27
  it("parses a single complete event", async () => {
13
28
  const out = await collect(SSE.fromBytes(bytesOf("event: foo\ndata: hello\n\n")))
@@ -39,9 +54,77 @@ describe("SSE.fromBytes", () => {
39
54
  expect(out).toEqual([{ id: "42", data: "x" }])
40
55
  })
41
56
 
42
- it("flushes a trailing event without a closing blank line", async () => {
57
+ it("parses a data field with no space after the colon", async () => {
58
+ const out = await collect(SSE.fromBytes(bytesOf("data:x\n\n")))
59
+ expect(out).toEqual([{ data: "x" }])
60
+ })
61
+
62
+ it("ignores an id containing a NUL character", async () => {
63
+ const out = await collect(SSE.fromBytes(bytesOf("id: a\u0000b\ndata: x\n\n")))
64
+ expect(out).toEqual([{ data: "x" }])
65
+ })
66
+
67
+ it("discards a trailing event without a closing blank line", async () => {
43
68
  const out = await collect(SSE.fromBytes(bytesOf("data: tail")))
44
- expect(out).toEqual([{ data: "tail" }])
69
+ expect(out).toEqual([])
70
+ })
71
+
72
+ it("discards buffered partial data when the upstream fails mid-stream", async () => {
73
+ const failing = Stream.concat(
74
+ bytesOf("data: good\n\ndata: partial-tr"),
75
+ Stream.fail("boom" as const),
76
+ )
77
+ const { seen, failures } = await runUntilEnd(SSE.fromBytes(failing))
78
+ expect(seen).toEqual([{ data: "good" }])
79
+ expect(failures).toEqual(["boom"])
80
+ })
81
+
82
+ it("discards a partial multi-byte char in the text decoder when the upstream fails", async () => {
83
+ const partialSquid = enc.encode("data: 🦑\n\n").slice(0, 8)
84
+ const failing = Stream.concat(
85
+ Stream.fromIterable([enc.encode("data: ok\n\n"), partialSquid]),
86
+ Stream.fail("boom" as const),
87
+ )
88
+ const { seen, failures } = await runUntilEnd(SSE.fromBytes(failing))
89
+ expect(seen).toEqual([{ data: "ok" }])
90
+ expect(failures).toEqual(["boom"])
91
+ })
92
+
93
+ it("discards buffered partial data when the stream is interrupted", async () => {
94
+ const { seen, exit } = await Effect.runPromise(
95
+ Effect.gen(function* () {
96
+ const seen: Array<SSE.Event> = []
97
+ const latch = yield* Deferred.make<void>()
98
+ const hanging = Stream.concat(bytesOf("data: good\n\ndata: partial"), Stream.never)
99
+ const fiber = yield* Stream.runForEach(SSE.fromBytes(hanging), (e) =>
100
+ Effect.sync(() => {
101
+ seen.push(e)
102
+ }).pipe(Effect.andThen(Deferred.succeed(latch, void 0))),
103
+ ).pipe(Effect.forkChild)
104
+ yield* Deferred.await(latch)
105
+ yield* Fiber.interrupt(fiber)
106
+ const exit = yield* Fiber.await(fiber)
107
+ return { seen, exit }
108
+ }),
109
+ )
110
+ expect(seen).toEqual([{ data: "good" }])
111
+ expect(Exit.isFailure(exit)).toBe(true)
112
+ expect(failuresOf(exit)).toEqual([])
113
+ })
114
+
115
+ it("ignores retry directives", async () => {
116
+ const out = await collect(SSE.fromBytes(bytesOf("retry: 3000\n\ndata: a\n\n")))
117
+ expect(out).toEqual([{ data: "a" }])
118
+ })
119
+
120
+ it("treats an explicit 'event: message' as the default event", async () => {
121
+ const out = await collect(SSE.fromBytes(bytesOf("event: message\ndata: x\n\n")))
122
+ expect(out).toEqual([{ data: "x" }])
123
+ })
124
+
125
+ it("does not dispatch events with no data", async () => {
126
+ const out = await collect(SSE.fromBytes(bytesOf("event: ping\n\ndata: x\n\n")))
127
+ expect(out).toEqual([{ data: "x" }])
45
128
  })
46
129
 
47
130
  it("ignores empty blocks between events", async () => {
@@ -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
+ )
@@ -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
+ })
package/src/tool/Tool.ts CHANGED
@@ -244,37 +244,114 @@ export type ToolDescriptor = {
244
244
  readonly strict?: boolean
245
245
  }
246
246
 
247
+ const resolveInputSchema = (
248
+ schema: ToolInputSchema<any> | Schema.Codec<any, any, never, any>,
249
+ ): ToolInputSchema<any> =>
250
+ Schema.isSchema(schema)
251
+ ? fromEffectSchema(schema as Schema.Codec<any, any, never, any>)
252
+ : (schema as ToolInputSchema<any>)
253
+
247
254
  /** A model-visible tool with a local `Effect` executor. */
248
- export const make = <Name extends string, Input, Event, Output, E = never, R = never>(
249
- spec: Omit<Tool<Name, Input, Event, Output, E, R>, "_tag">,
250
- ): Tool<Name, Input, Event, Output, E, R> => ({ _tag: "LocalTool", ...spec })
255
+ export const make: {
256
+ <
257
+ Name extends string,
258
+ S extends Schema.Codec<any, any, never, any>,
259
+ Event,
260
+ Output,
261
+ E = never,
262
+ R = never,
263
+ >(
264
+ spec: Omit<Tool<Name, S["Type"], Event, Output, E, R>, "_tag" | "inputSchema"> & {
265
+ readonly inputSchema: S
266
+ },
267
+ ): Tool<Name, S["Type"], Event, Output, E, R>
268
+ <Name extends string, Input, Event, Output, E = never, R = never>(
269
+ spec: Omit<Tool<Name, Input, Event, Output, E, R>, "_tag">,
270
+ ): Tool<Name, Input, Event, Output, E, R>
271
+ } = (
272
+ spec: Omit<AnyLocalTool, "_tag" | "inputSchema"> & {
273
+ readonly inputSchema: ToolInputSchema<any> | Schema.Codec<any, any, never, any>
274
+ },
275
+ ): AnyLocalTool => ({
276
+ _tag: "LocalTool",
277
+ ...spec,
278
+ inputSchema: resolveInputSchema(spec.inputSchema),
279
+ })
251
280
 
252
281
  /**
253
282
  * A provider-hosted tool (native web search, code execution, RAG grounding).
254
283
  * No local executor — `provider`/`config` drive the provider adapter's
255
284
  * rendering. The model sees it; `Toolkit.run` reports it as `non_local_tool`.
256
285
  */
257
- export const provider = <Name extends string, Input, Provider extends string, Config>(
258
- spec: Omit<ProviderTool<Name, Input, Provider, Config>, "_tag">,
259
- ): ProviderTool<Name, Input, Provider, Config> => ({ _tag: "ProviderTool", ...spec })
286
+ export const provider: {
287
+ <
288
+ Name extends string,
289
+ S extends Schema.Codec<any, any, never, any>,
290
+ Provider extends string,
291
+ Config,
292
+ >(
293
+ spec: Omit<ProviderTool<Name, S["Type"], Provider, Config>, "_tag" | "inputSchema"> & {
294
+ readonly inputSchema: S
295
+ },
296
+ ): ProviderTool<Name, S["Type"], Provider, Config>
297
+ <Name extends string, Input, Provider extends string, Config>(
298
+ spec: Omit<ProviderTool<Name, Input, Provider, Config>, "_tag">,
299
+ ): ProviderTool<Name, Input, Provider, Config>
300
+ } = (
301
+ spec: Omit<ProviderTool<string, any, string, any>, "_tag" | "inputSchema"> & {
302
+ readonly inputSchema: ToolInputSchema<any> | Schema.Codec<any, any, never, any>
303
+ },
304
+ ): ProviderTool<string, any, string, any> => ({
305
+ _tag: "ProviderTool",
306
+ ...spec,
307
+ inputSchema: resolveInputSchema(spec.inputSchema),
308
+ })
260
309
 
261
310
  /**
262
311
  * A typed control-flow signal to the agent loop (escalate, pause, schedule,
263
312
  * hand off). Decode-only: the loop interprets the call rather than executing a
264
313
  * handler, so there is no `run`.
265
314
  */
266
- export const signal = <Name extends string, Input>(
267
- spec: Omit<SignalTool<Name, Input>, "_tag">,
268
- ): SignalTool<Name, Input> => ({ _tag: "SignalTool", ...spec })
315
+ export const signal: {
316
+ <Name extends string, S extends Schema.Codec<any, any, never, any>>(
317
+ spec: Omit<SignalTool<Name, S["Type"]>, "_tag" | "inputSchema"> & {
318
+ readonly inputSchema: S
319
+ },
320
+ ): SignalTool<Name, S["Type"]>
321
+ <Name extends string, Input>(spec: Omit<SignalTool<Name, Input>, "_tag">): SignalTool<Name, Input>
322
+ } = (
323
+ spec: Omit<SignalTool<string, any>, "_tag" | "inputSchema"> & {
324
+ readonly inputSchema: ToolInputSchema<any> | Schema.Codec<any, any, never, any>
325
+ },
326
+ ): SignalTool<string, any> => ({
327
+ _tag: "SignalTool",
328
+ ...spec,
329
+ inputSchema: resolveInputSchema(spec.inputSchema),
330
+ })
269
331
 
270
332
  /**
271
333
  * A typed request for an external actor to respond before the loop resumes
272
334
  * (frontend, Telegram, CLI prompt). Decode-only; the loop stops/pauses and
273
335
  * resumes by appending the actor's `function_call_output`.
274
336
  */
275
- export const interaction = <Name extends string, Input>(
276
- spec: Omit<InteractionTool<Name, Input>, "_tag">,
277
- ): InteractionTool<Name, Input> => ({ _tag: "InteractionTool", ...spec })
337
+ export const interaction: {
338
+ <Name extends string, S extends Schema.Codec<any, any, never, any>>(
339
+ spec: Omit<InteractionTool<Name, S["Type"]>, "_tag" | "inputSchema"> & {
340
+ readonly inputSchema: S
341
+ },
342
+ ): InteractionTool<Name, S["Type"]>
343
+ <Name extends string, Input>(
344
+ spec: Omit<InteractionTool<Name, Input>, "_tag">,
345
+ ): InteractionTool<Name, Input>
346
+ } = (
347
+ spec: Omit<InteractionTool<string, any>, "_tag" | "inputSchema"> & {
348
+ readonly inputSchema: ToolInputSchema<any> | Schema.Codec<any, any, never, any>
349
+ },
350
+ ): InteractionTool<string, any> => ({
351
+ _tag: "InteractionTool",
352
+ ...spec,
353
+ inputSchema: resolveInputSchema(spec.inputSchema),
354
+ })
278
355
 
279
356
  /**
280
357
  * Return a copy of a tool under a new `name`. Useful for resolving a name
@@ -312,18 +389,39 @@ export const withRun = <
312
389
  run: (input: Input, emit: Emit<Event>) => Effect.Effect<Output2, E2, R2>,
313
390
  ): Tool<Name, Input, Event, Output2, E2, R2> => ({ ...tool, run })
314
391
 
315
- /** Render tools to provider-agnostic descriptors. Every kind is model-visible. */
392
+ /** True for provider-hosted tools — the kind a provider renders natively. */
393
+ export const isProviderTool = (tool: AnyTool): tool is ProviderTool<string, any, string, any> =>
394
+ tool._tag === "ProviderTool"
395
+
396
+ /**
397
+ * The `ProviderTool`s in a toolkit, for a provider adapter to render as its
398
+ * native hosted-tool entries (web search, code execution, grounding). The other
399
+ * kinds render as function declarations via `descriptorsOf`.
400
+ */
401
+ export const providerToolsOf = (
402
+ toolkit?: Readonly<Record<string, AnyTool>>,
403
+ ): ReadonlyArray<ProviderTool<string, any, string, any>> =>
404
+ toolkit === undefined ? [] : Object.values(toolkit).filter(isProviderTool)
405
+
406
+ /**
407
+ * Render tools to provider-agnostic descriptors. `ProviderTool`s are excluded:
408
+ * they are provider-hosted and must never leak into the function-declaration
409
+ * list — each adapter renders them natively via `providerToolsOf`. Every other
410
+ * kind is model-visible as a declaration.
411
+ */
316
412
  export const toDescriptors = (tools: ReadonlyArray<AnyTool>): ReadonlyArray<ToolDescriptor> =>
317
- tools.map((tool) => {
318
- const inputSchema = tool.inputSchema["~standard"].jsonSchema.input({
319
- target: "draft-2020-12",
413
+ tools
414
+ .filter((tool) => !isProviderTool(tool))
415
+ .map((tool) => {
416
+ const inputSchema = tool.inputSchema["~standard"].jsonSchema.input({
417
+ target: "draft-2020-12",
418
+ })
419
+ // `strict` only exists on local/provider kinds; signals/interactions omit it.
420
+ const strict = "strict" in tool ? tool.strict : undefined
421
+ return strict !== undefined
422
+ ? { name: tool.name, description: tool.description, inputSchema, strict }
423
+ : { name: tool.name, description: tool.description, inputSchema }
320
424
  })
321
- // `strict` only exists on local/provider kinds; signals/interactions omit it.
322
- const strict = "strict" in tool ? tool.strict : undefined
323
- return strict !== undefined
324
- ? { name: tool.name, description: tool.description, inputSchema, strict }
325
- : { name: tool.name, description: tool.description, inputSchema }
326
- })
327
425
 
328
426
  /**
329
427
  * Render a `Toolkit` (a name-indexed record of tools) to descriptors, treating
@@ -252,6 +252,66 @@ describe("Toolkit.make / compose / namespace - uniqueness", () => {
252
252
  })
253
253
  })
254
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
+
255
315
  describe("Toolkit.wrap - middleware", () => {
256
316
  const Empty = Schema.Struct({})
257
317
  type AuditShape = { readonly record: (name: string) => Effect.Effect<void> }
@@ -186,8 +186,8 @@ export class DuplicateToolName extends Data.TaggedError("DuplicateToolName")<{
186
186
  type PrefixName<Prefix extends string, Name extends string> = `${Prefix}__${Name}`
187
187
 
188
188
  type PrefixTool<Prefix extends string, T extends AnyTool> =
189
- T extends Tool<infer N, infer I, infer Ev, infer O, infer R>
190
- ? 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>
191
191
  : T extends ProviderTool<infer N, infer I, infer P, infer C>
192
192
  ? ProviderTool<PrefixName<Prefix, N>, I, P, C>
193
193
  : T extends SignalTool<infer N, infer I>