@effect-uai/core 0.7.0 → 0.9.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 (137) hide show
  1. package/dist/{AiError-CAX_48RU.d.mts → AiError-CrdR1EQi.d.mts} +2 -2
  2. package/dist/{AiError-CAX_48RU.d.mts.map → AiError-CrdR1EQi.d.mts.map} +1 -1
  3. package/dist/{Audio-DPlTsNdz.d.mts → Audio-DDSKCkSe.d.mts} +2 -2
  4. package/dist/{Audio-DPlTsNdz.d.mts.map → Audio-DDSKCkSe.d.mts.map} +1 -1
  5. package/dist/{Image-HNmMpMTh.d.mts → Image-C_Zjpwhc.d.mts} +2 -2
  6. package/dist/{Image-HNmMpMTh.d.mts.map → Image-C_Zjpwhc.d.mts.map} +1 -1
  7. package/dist/{Items-D8UaXQx_.d.mts → Items-CgKD6Npw.d.mts} +5 -5
  8. package/dist/{Items-D8UaXQx_.d.mts.map → Items-CgKD6Npw.d.mts.map} +1 -1
  9. package/dist/Loop-B5_cqumi.d.mts +173 -0
  10. package/dist/Loop-B5_cqumi.d.mts.map +1 -0
  11. package/dist/{Media-D_CpcM1Z.d.mts → Media-DOLyaygI.d.mts} +1 -1
  12. package/dist/{Media-D_CpcM1Z.d.mts.map → Media-DOLyaygI.d.mts.map} +1 -1
  13. package/dist/{SandboxError-BvKypkJm.d.mts → SandboxError-CIrVDOMV.d.mts} +2 -2
  14. package/dist/{SandboxError-BvKypkJm.d.mts.map → SandboxError-CIrVDOMV.d.mts.map} +1 -1
  15. package/dist/{StructuredFormat-BbN4dosH.d.mts → StructuredFormat-CMJFD3sn.d.mts} +2 -2
  16. package/dist/{StructuredFormat-BbN4dosH.d.mts.map → StructuredFormat-CMJFD3sn.d.mts.map} +1 -1
  17. package/dist/Tool-0XUIT2A2.d.mts +232 -0
  18. package/dist/Tool-0XUIT2A2.d.mts.map +1 -0
  19. package/dist/ToolEvent-DYrAUJGs.d.mts +151 -0
  20. package/dist/ToolEvent-DYrAUJGs.d.mts.map +1 -0
  21. package/dist/ToolResult-BZd-cEWW.d.mts +151 -0
  22. package/dist/ToolResult-BZd-cEWW.d.mts.map +1 -0
  23. package/dist/Toolkit-BKC8b8Xo.d.mts +155 -0
  24. package/dist/Toolkit-BKC8b8Xo.d.mts.map +1 -0
  25. package/dist/{dist-DV5ISja1.mjs → dist-B04dlFHg.mjs} +3 -3
  26. package/dist/{dist-DV5ISja1.mjs.map → dist-B04dlFHg.mjs.map} +1 -1
  27. package/dist/dist-DWgRbDN3.mjs +140 -0
  28. package/dist/dist-DWgRbDN3.mjs.map +1 -0
  29. package/dist/domain/AiError.d.mts +1 -1
  30. package/dist/domain/Audio.d.mts +1 -1
  31. package/dist/domain/Image.d.mts +1 -1
  32. package/dist/domain/Items.d.mts +1 -1
  33. package/dist/domain/Media.d.mts +1 -1
  34. package/dist/domain/Music.d.mts +1 -1
  35. package/dist/domain/Turn.d.mts +4 -4
  36. package/dist/domain/Turn.test.mjs +1 -1
  37. package/dist/embedding-model/Embedding.d.mts +1 -1
  38. package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
  39. package/dist/embedding-model/EmbeddingModel.test.mjs +1 -1
  40. package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -1
  41. package/dist/index.d.mts +16 -13
  42. package/dist/index.mjs +5 -2
  43. package/dist/language-model/LanguageModel.d.mts +10 -5
  44. package/dist/language-model/LanguageModel.d.mts.map +1 -1
  45. package/dist/language-model/LanguageModel.mjs.map +1 -1
  46. package/dist/language-model/LanguageModel.test.mjs +1 -1
  47. package/dist/loop/Loop.d.mts +2 -141
  48. package/dist/loop/Loop.mjs +16 -20
  49. package/dist/loop/Loop.mjs.map +1 -1
  50. package/dist/loop/Loop.test.mjs +18 -1
  51. package/dist/loop/Loop.test.mjs.map +1 -1
  52. package/dist/{magic-string.es-BgIV5Mu3.mjs → magic-string.es-Dk-MMA0F.mjs} +1 -1
  53. package/dist/{magic-string.es-BgIV5Mu3.mjs.map → magic-string.es-Dk-MMA0F.mjs.map} +1 -1
  54. package/dist/music-generator/MusicGenerator.d.mts +2 -2
  55. package/dist/music-generator/MusicGenerator.test.mjs +1 -1
  56. package/dist/observability/Metrics.d.mts +116 -28
  57. package/dist/observability/Metrics.d.mts.map +1 -1
  58. package/dist/observability/Metrics.mjs +250 -38
  59. package/dist/observability/Metrics.mjs.map +1 -1
  60. package/dist/observability/Metrics.test.d.mts +1 -0
  61. package/dist/observability/Metrics.test.mjs +187 -0
  62. package/dist/observability/Metrics.test.mjs.map +1 -0
  63. package/dist/observability/Telemetry.d.mts +37 -0
  64. package/dist/observability/Telemetry.d.mts.map +1 -0
  65. package/dist/observability/Telemetry.mjs +57 -0
  66. package/dist/observability/Telemetry.mjs.map +1 -0
  67. package/dist/observability/Telemetry.test.d.mts +1 -0
  68. package/dist/observability/Telemetry.test.mjs +92 -0
  69. package/dist/observability/Telemetry.test.mjs.map +1 -0
  70. package/dist/retry/Retry.d.mts +2 -2
  71. package/dist/retry/Retry.test.mjs +1 -1
  72. package/dist/sandbox/Sandbox.d.mts +1 -1
  73. package/dist/sandbox/SandboxError.d.mts +1 -1
  74. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +2 -2
  75. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +1 -1
  76. package/dist/streaming/JSONL.d.mts +1 -1
  77. package/dist/streaming/JSONL.test.mjs +1 -1
  78. package/dist/streaming/SSE.test.mjs +1 -1
  79. package/dist/structured-format/StructuredFormat.d.mts +1 -1
  80. package/dist/structured-format/StructuredFormat.mjs.map +1 -1
  81. package/dist/structured-format/StructuredFormat.test.mjs +1 -1
  82. package/dist/testing/MockMusicGenerator.d.mts +1 -1
  83. package/dist/testing/MockProvider.d.mts +1 -1
  84. package/dist/testing/MockSpeechSynthesizer.d.mts +1 -1
  85. package/dist/tool/Approval.d.mts +3 -3
  86. package/dist/tool/Approval.test.mjs +35 -34
  87. package/dist/tool/Approval.test.mjs.map +1 -1
  88. package/dist/tool/HistoryCheck.d.mts +2 -2
  89. package/dist/tool/Tool.d.mts +2 -2
  90. package/dist/tool/Tool.mjs +112 -22
  91. package/dist/tool/Tool.mjs.map +1 -1
  92. package/dist/tool/Tool.test.mjs +41 -3
  93. package/dist/tool/Tool.test.mjs.map +1 -1
  94. package/dist/tool/ToolEvent.d.mts +2 -151
  95. package/dist/tool/ToolResult.d.mts +2 -138
  96. package/dist/tool/ToolResult.mjs +18 -3
  97. package/dist/tool/ToolResult.mjs.map +1 -1
  98. package/dist/tool/Toolkit.d.mts +2 -73
  99. package/dist/tool/Toolkit.mjs +106 -28
  100. package/dist/tool/Toolkit.mjs.map +1 -1
  101. package/dist/tool/Toolkit.test.mjs +113 -7
  102. package/dist/tool/Toolkit.test.mjs.map +1 -1
  103. package/dist/transcriber/Transcriber.d.mts +2 -2
  104. package/dist/transcriber/Transcriber.test.mjs +1 -1
  105. package/dist/web-search/WebSearch.d.mts +119 -0
  106. package/dist/web-search/WebSearch.d.mts.map +1 -0
  107. package/dist/web-search/WebSearch.mjs +42 -0
  108. package/dist/web-search/WebSearch.mjs.map +1 -0
  109. package/dist/web-search/WebSearchTool.d.mts +58 -0
  110. package/dist/web-search/WebSearchTool.d.mts.map +1 -0
  111. package/dist/web-search/WebSearchTool.mjs +69 -0
  112. package/dist/web-search/WebSearchTool.mjs.map +1 -0
  113. package/package.json +9 -1
  114. package/src/embedding-model/EmbeddingModel.test.ts +4 -0
  115. package/src/index.ts +3 -0
  116. package/src/language-model/LanguageModel.ts +7 -2
  117. package/src/loop/Loop.test.ts +22 -0
  118. package/src/loop/Loop.ts +27 -4
  119. package/src/observability/Metrics.test.ts +266 -0
  120. package/src/observability/Metrics.ts +505 -65
  121. package/src/observability/Telemetry.test.ts +123 -0
  122. package/src/observability/Telemetry.ts +96 -0
  123. package/src/structured-format/StructuredFormat.ts +4 -0
  124. package/src/tool/Approval.test.ts +22 -21
  125. package/src/tool/Tool.test.ts +55 -2
  126. package/src/tool/Tool.ts +221 -55
  127. package/src/tool/ToolResult.ts +22 -1
  128. package/src/tool/Toolkit.test.ts +168 -7
  129. package/src/tool/Toolkit.ts +336 -99
  130. package/src/web-search/WebSearch.ts +150 -0
  131. package/src/web-search/WebSearchTool.ts +106 -0
  132. package/dist/Tool-CiFXYjmR.d.mts +0 -102
  133. package/dist/Tool-CiFXYjmR.d.mts.map +0 -1
  134. package/dist/loop/Loop.d.mts.map +0 -1
  135. package/dist/tool/ToolEvent.d.mts.map +0 -1
  136. package/dist/tool/ToolResult.d.mts.map +0 -1
  137. package/dist/tool/Toolkit.d.mts.map +0 -1
@@ -1,35 +1,306 @@
1
- import { Array as Arr, Effect, Function, Ref, Schema, Stream } from "effect"
1
+ import {
2
+ Array as Arr,
3
+ Cause,
4
+ Data,
5
+ Effect,
6
+ Fiber,
7
+ Function,
8
+ Match,
9
+ Option,
10
+ Queue,
11
+ Record,
12
+ Ref,
13
+ Schema,
14
+ Stream,
15
+ } from "effect"
2
16
  import * as Loop from "../loop/Loop.js"
3
- import type { ToolCall, HistoryItem } from "../domain/Items.js"
17
+ import type { HistoryItem, ToolCall } from "../domain/Items.js"
4
18
  import { appendToHistory, type Turn } from "../domain/Turn.js"
5
19
  import {
20
+ type AnyLocalTool,
6
21
  type AnyTool,
7
- type AnyPlainTool,
8
- type AnyStreamingTool,
9
- isStreamingTool,
10
- type StreamingTool,
22
+ type Emit,
23
+ type InteractionTool,
24
+ type ProviderTool,
25
+ type SignalTool,
11
26
  type Tool,
27
+ type ToolDescriptor,
28
+ type ToolR,
29
+ toDescriptors,
12
30
  } from "./Tool.js"
13
- import { ToolResult, executionError, failed, toToolCallOutput } from "./ToolResult.js"
31
+ import {
32
+ ToolResult,
33
+ executionError,
34
+ failed,
35
+ nonLocalTool,
36
+ toToolCallOutput,
37
+ validationError,
38
+ } from "./ToolResult.js"
14
39
  import { ToolEvent } from "./ToolEvent.js"
15
40
  import { isOutput } from "./ToolEvent.js"
16
41
 
42
+ // ---------------------------------------------------------------------------
43
+ // Toolkit: a name-indexed record of tools — "what the model sees". The record
44
+ // gives O(1) dispatch and within-toolkit name-uniqueness for free; descriptors
45
+ // are derived, not stored. Build a static one with `make` (compile-time
46
+ // duplicate-name check), a dynamic one with `fromArray` (trusted single source,
47
+ // last-wins), and combine independent ones with `compose` (the application
48
+ // boundary where cross-source names can clash).
49
+ // ---------------------------------------------------------------------------
50
+
51
+ export type ToolMap = Record<string, AnyTool>
52
+
53
+ export type Toolkit<Tools extends ToolMap = ToolMap> = Tools
54
+
55
+ const indexByName = (tools: ReadonlyArray<AnyTool>): ToolMap =>
56
+ Object.fromEntries(tools.map((tool) => [tool.name, tool]))
57
+
17
58
  /**
18
- * Union of every tool's `R` requirements in a mixed plain + streaming array.
19
- * Used by `run` to surface the services tools need at the recipe level, so
20
- * the loop's stream type carries them through to `Effect.provide`.
59
+ * Index literal tools by their `name`. Duplicate names are a compile error
60
+ * (`UniqueTools`). Names are not otherwise checked: a malformed name is a
61
+ * developer typo in a literal that the provider rejects clearly on first use,
62
+ * and there is no single cross-provider name rule to enforce here anyway.
21
63
  */
22
- export type ToolKindR<Tools extends ReadonlyArray<AnyTool<any>>> =
23
- Tools[number] extends StreamingTool<any, any, any, any, infer R>
24
- ? R
25
- : Tools[number] extends Tool<any, any, any, infer R>
26
- ? R
27
- : never
64
+ const buildStatic = <Tools extends ReadonlyArray<AnyTool>>(
65
+ tools: Tools,
66
+ ): Toolkit<{ [T in Tools[number] as T["name"]]: T }> =>
67
+ indexByName(tools) as Toolkit<{ [T in Tools[number] as T["name"]]: T }>
68
+
69
+ export const make = <const Tools extends ReadonlyArray<AnyTool>>(
70
+ ...tools: UniqueTools<Tools>
71
+ ): Toolkit<{ [T in Tools[number] as T["name"]]: T }> => buildStatic(tools as unknown as Tools)
72
+
73
+ /**
74
+ * Build a toolkit from a dynamically-sourced array (MCP server, plugin). Names
75
+ * are not validated (MCP legitimately uses dots / >64 chars — sanitize at the
76
+ * provider adapter); same-named tools resolve last-wins. Cross-source clashes
77
+ * are caught by `compose`.
78
+ */
79
+ export const fromArray = (tools: ReadonlyArray<AnyTool>): Toolkit => indexByName(tools)
80
+
81
+ /** Render the provider-facing descriptors for a toolkit on demand. */
82
+ export const descriptors = (toolkit: Toolkit): ReadonlyArray<ToolDescriptor> =>
83
+ toDescriptors(Object.values(toolkit))
84
+
85
+ /**
86
+ * Union of every tool's `R` requirements in a toolkit. Surfaced by `run` so the
87
+ * loop's stream type carries the services tools need through to `Effect.provide`.
88
+ *
89
+ * The `ToolMap extends T` guard yields `never` for the wide `Toolkit` type (a
90
+ * toolkit passed through an untyped boundary, where the requirements aren't
91
+ * statically known) instead of `any`, which would otherwise poison the
92
+ * resulting stream's `R`. Concrete toolkits from `make` keep their precise R.
93
+ */
94
+ export type ToolkitR<T extends Toolkit> = ToolMap extends T ? never : ToolR<T[keyof T]>
28
95
 
29
96
  // ---------------------------------------------------------------------------
30
- // Tool executor. Streams `ToolEvent`s in real time and dispatches streaming
31
- // and plain tools uniformly. Policy stays outside this module: callers pass
32
- // only the calls they have already decided should run.
97
+ // Uniqueness & composition. `make` rejects duplicate literal names at compile
98
+ // time; `compose` rejects cross-source collisions — at compile time when the
99
+ // names are statically known, and at runtime (`DuplicateToolName`) for dynamic
100
+ // sources whose keys are only `string`. No silent last-wins overwrite, no late
101
+ // provider 400.
102
+ // ---------------------------------------------------------------------------
103
+
104
+ // --- compile-time duplicate detection --------------------------------------
105
+
106
+ type DuplicateName<
107
+ Tools extends ReadonlyArray<AnyTool>,
108
+ Seen extends string = never,
109
+ > = Tools extends readonly [
110
+ infer Head extends AnyTool,
111
+ ...infer Tail extends ReadonlyArray<AnyTool>,
112
+ ]
113
+ ? Head["name"] extends Seen
114
+ ? Head["name"]
115
+ : DuplicateName<Tail, Seen | Head["name"]>
116
+ : never
117
+
118
+ type DuplicateNameError<Name extends string> = {
119
+ readonly __error: `Duplicate tool name: ${Name}`
120
+ }
121
+
122
+ export type NoDuplicateNames<Tools extends ReadonlyArray<AnyTool>> = [
123
+ DuplicateName<Tools>,
124
+ ] extends [never]
125
+ ? unknown
126
+ : DuplicateNameError<DuplicateName<Tools>>
127
+
128
+ /** `Tools` constrained so a repeated literal name is a compile error in `make`. */
129
+ export type UniqueTools<Tools extends ReadonlyArray<AnyTool>> = Tools & NoDuplicateNames<Tools>
130
+
131
+ // Cross-toolkit literal-key collision, skipping wide (`string`-keyed, dynamic)
132
+ // toolkits so the compile-time check never false-positives on `fromArray`.
133
+ type ComposeDuplicateKey<
134
+ Kits extends ReadonlyArray<Toolkit>,
135
+ Seen extends string = never,
136
+ > = Kits extends readonly [infer Head extends Toolkit, ...infer Tail extends ReadonlyArray<Toolkit>]
137
+ ? string extends keyof Head
138
+ ? ComposeDuplicateKey<Tail, Seen>
139
+ : Extract<keyof Head & string, Seen> extends never
140
+ ? ComposeDuplicateKey<Tail, Seen | (keyof Head & string)>
141
+ : Extract<keyof Head & string, Seen>
142
+ : never
143
+
144
+ type NoDuplicateComposedKeys<Kits extends ReadonlyArray<Toolkit>> = [
145
+ ComposeDuplicateKey<Kits>,
146
+ ] extends [never]
147
+ ? unknown
148
+ : DuplicateNameError<ComposeDuplicateKey<Kits>>
149
+
150
+ type UnionToIntersection<U> = (U extends unknown ? (x: U) => void : never) extends (
151
+ x: infer I,
152
+ ) => void
153
+ ? I
154
+ : never
155
+
156
+ type Composed<Kits extends ReadonlyArray<Toolkit>> =
157
+ UnionToIntersection<Kits[number]> extends infer M ? (M extends ToolMap ? M : ToolMap) : ToolMap
158
+
159
+ // --- uniqueness error ------------------------------------------------------
160
+
161
+ /**
162
+ * Two composed toolkits resolved to the same final name. Carries the colliding
163
+ * `sources` (the positions of the clashing toolkits) so the error says *which*
164
+ * inputs collided. Raised at runtime by `compose` for dynamic sources; static
165
+ * collisions are caught earlier at compile time.
166
+ */
167
+ export class DuplicateToolName extends Data.TaggedError("DuplicateToolName")<{
168
+ readonly name: string
169
+ readonly sources: ReadonlyArray<string>
170
+ }> {}
171
+
172
+ // --- namespacing -----------------------------------------------------------
173
+
174
+ type PrefixName<Prefix extends string, Name extends string> = `${Prefix}__${Name}`
175
+
176
+ 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>
179
+ : T extends ProviderTool<infer N, infer I, infer P, infer C>
180
+ ? ProviderTool<PrefixName<Prefix, N>, I, P, C>
181
+ : T extends SignalTool<infer N, infer I>
182
+ ? SignalTool<PrefixName<Prefix, N>, I>
183
+ : T extends InteractionTool<infer N, infer I>
184
+ ? InteractionTool<PrefixName<Prefix, N>, I>
185
+ : T
186
+
187
+ type Namespaced<Prefix extends string, T extends ToolMap> = {
188
+ readonly [K in keyof T & string as PrefixName<Prefix, K>]: T[K] extends AnyTool
189
+ ? PrefixTool<Prefix, T[K]>
190
+ : never
191
+ }
192
+
193
+ /**
194
+ * Prefix every tool name with `<namespace>__`, returning a new toolkit. Use it
195
+ * to keep generic names (`search`) from two sources distinct before `compose`.
196
+ * If two namespaces collide at the `__` separator, the duplicate final name
197
+ * surfaces from `compose` as a `DuplicateToolName`.
198
+ */
199
+ export const namespace = <const Prefix extends string, const T extends ToolMap>(
200
+ prefix: Prefix,
201
+ toolkit: Toolkit<T>,
202
+ ): Toolkit<Namespaced<Prefix, T>> => {
203
+ const renamed = Arr.map(Record.values(toolkit), (tool) => ({
204
+ ...tool,
205
+ name: `${prefix}__${tool.name}`,
206
+ }))
207
+ return indexByName(renamed) as Toolkit<Namespaced<Prefix, T>>
208
+ }
209
+
210
+ /** `namespace(prefix, make(...tools))` in one step, for static namespaced tools. */
211
+ export const makeNamespaced = <
212
+ const Prefix extends string,
213
+ const Tools extends ReadonlyArray<AnyTool>,
214
+ >(
215
+ prefix: Prefix,
216
+ ...tools: UniqueTools<Tools>
217
+ ): Toolkit<Namespaced<Prefix, { [T in Tools[number] as T["name"]]: T }>> =>
218
+ namespace(prefix, buildStatic(tools as unknown as Tools))
219
+
220
+ // --- composition (the application boundary) --------------------------------
221
+
222
+ /**
223
+ * Combine independently-built toolkits into one. This is where names from
224
+ * separate sources can collide, so it is the one effectful constructor: a
225
+ * duplicate final name fails with `DuplicateToolName` (naming the colliding
226
+ * input positions) instead of silently overwriting or 400-ing later at the
227
+ * provider. Statically-known collisions are additionally a compile error.
228
+ */
229
+ export const compose = <const Kits extends ReadonlyArray<Toolkit>>(
230
+ ...toolkits: Kits & NoDuplicateComposedKeys<Kits>
231
+ ): Effect.Effect<Toolkit<Composed<Kits>>, DuplicateToolName> =>
232
+ Effect.gen(function* () {
233
+ const kits = toolkits as unknown as ReadonlyArray<Toolkit>
234
+ const labelled = Arr.flatMap(kits, (kit, i) =>
235
+ Arr.map(Record.keys(kit), (name) => ({ name, source: `toolkit-${i}` })),
236
+ )
237
+ const collision = Arr.findFirst(
238
+ Record.toEntries(Arr.groupBy(labelled, (entry) => entry.name)),
239
+ ([, group]) => group.length > 1,
240
+ )
241
+ if (Option.isSome(collision)) {
242
+ const [name, group] = collision.value
243
+ return yield* new DuplicateToolName({ name, sources: Arr.map(group, (e) => e.source) })
244
+ }
245
+ return Arr.reduce(kits, {} as ToolMap, (acc, kit) => ({ ...acc, ...kit })) as Toolkit<
246
+ Composed<Kits>
247
+ >
248
+ })
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Middleware: a `Toolkit -> Toolkit` transform applied up front, then the
252
+ // wrapped toolkit is run. A middleware transforms one local tool's `run` given
253
+ // its name (logging, retry, auth, metrics); `wrap` lifts it over the whole
254
+ // toolkit, leaving provider/signal/interaction kinds untouched. The one reason
255
+ // this is a combinator rather than native `Record.map`: it tracks the
256
+ // middleware's added requirement `R2`, unioning it into `ToolkitR` instead of
257
+ // widening every tool's type.
258
+ // ---------------------------------------------------------------------------
259
+
260
+ type Handler<Input, Event, Output, R> = (
261
+ input: Input,
262
+ emit: Emit<Event>,
263
+ ) => Effect.Effect<Output, unknown, R>
264
+
265
+ export type Middleware<R2 = never> = <Input, Event, Output, R>(
266
+ run: Handler<Input, Event, Output, R>,
267
+ name: string,
268
+ ) => Handler<Input, Event, Output, R | R2>
269
+
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
272
+
273
+ type WrapToolkit<T extends ToolMap, R2> = {
274
+ readonly [K in keyof T]: T[K] extends AnyTool ? WrapTool<T[K], R2> : T[K]
275
+ }
276
+
277
+ /**
278
+ * Wrap every local tool's `run` with a middleware, preserving each tool's
279
+ * `Input`/`Output` and unioning the middleware's requirement `R2` into the
280
+ * toolkit's `ToolkitR`. Non-local kinds pass through unchanged. Stack with
281
+ * `pipe`:
282
+ *
283
+ * const observed = pipe(toolkit, Toolkit.wrap(logging), Toolkit.wrap(authz))
284
+ * Toolkit.run(observed, calls) // R = ToolkitR<typeof toolkit> | <authz R>
285
+ *
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.
289
+ */
290
+ 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>
293
+ } = Function.dual(
294
+ 2,
295
+ <T extends Toolkit, R2>(toolkit: T, middleware: Middleware<R2>): WrapToolkit<T, R2> =>
296
+ Record.map(toolkit, (tool) =>
297
+ tool._tag === "LocalTool" ? { ...tool, run: middleware(tool.run, tool.name) } : tool,
298
+ ) as WrapToolkit<T, R2>,
299
+ )
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // Tool executor. Streams `ToolEvent`s in real time. Policy stays outside this
303
+ // module: callers pass only the calls they have already decided should run.
33
304
  // ---------------------------------------------------------------------------
34
305
 
35
306
  export type ExecuteOptions = {
@@ -37,69 +308,40 @@ export type ExecuteOptions = {
37
308
  }
38
309
 
39
310
  /** Execute every provided call. Approval/rejection policy belongs upstream. */
40
- export const run = <Tools extends ReadonlyArray<AnyTool<any>>>(
41
- tools: Tools,
311
+ export const run = <T extends Toolkit>(
312
+ toolkit: T,
42
313
  calls: ReadonlyArray<ToolCall>,
43
314
  options?: ExecuteOptions,
44
- ): Stream.Stream<ToolEvent, never, ToolKindR<Tools>> =>
315
+ ): Stream.Stream<ToolEvent, never, ToolkitR<T>> =>
45
316
  Stream.fromIterable(calls).pipe(
46
- Stream.flatMap((call) => runOne(tools, call), {
317
+ Stream.flatMap((call) => runOne(toolkit, call), {
47
318
  concurrency: options?.concurrency ?? "unbounded",
48
319
  }),
49
- )
320
+ ) as Stream.Stream<ToolEvent, never, ToolkitR<T>>
50
321
 
51
322
  const okResult = (call: ToolCall, tool: string, value: unknown): ToolResult =>
52
- ToolResult.Ok({
53
- call_id: call.call_id,
54
- tool,
55
- value,
56
- })
323
+ ToolResult.Ok({ call_id: call.call_id, tool, value })
57
324
 
58
- const runOne = <R>(
59
- tools: ReadonlyArray<AnyTool<R>>,
60
- call: ToolCall,
61
- ): Stream.Stream<ToolEvent, never, R> => {
62
- const tool = tools.find((t) => t.name === call.name)
63
- if (tool === undefined) {
64
- // Graceful: emit a synthetic Failure so OTHER calls in this turn
65
- // still execute. LLMs hallucinate tool names; MCP tools come and go.
66
- return Stream.succeed(
67
- ToolEvent.Output({
68
- result: failed(call, "unknown_tool", `No tool registered with name "${call.name}"`),
69
- }),
70
- )
71
- }
72
- if (isStreamingTool(tool)) return runStreaming(tool, call)
73
- return runPlain(tool, call)
74
- }
325
+ const outputEvent = (result: ToolResult): Stream.Stream<ToolEvent, never, any> =>
326
+ Stream.succeed(ToolEvent.Output({ result }))
75
327
 
76
- const parseJsonUnknown = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
77
-
78
- const runPlain = <R>(tool: AnyPlainTool<R>, call: ToolCall): Stream.Stream<ToolEvent, never, R> =>
79
- Stream.fromEffect(
80
- Effect.gen(function* () {
81
- const parsed = yield* parseJsonUnknown(call.arguments).pipe(
82
- Effect.mapError(() => "json_parse_error" as const),
83
- )
84
- const validated = yield* Effect.tryPromise({
85
- try: () => Promise.resolve(tool.inputSchema["~standard"].validate(parsed)),
86
- catch: () => "validation_threw" as const,
87
- })
88
- if (validated.issues !== undefined) {
89
- return executionError(call, "Tool input failed schema validation")
90
- }
91
- const output = yield* tool.run(validated.value)
92
- return okResult(call, tool.name, output)
93
- }).pipe(
94
- Effect.catchCause(() => Effect.succeed(executionError(call, "Tool execution failed"))),
95
- Effect.map((result) => ToolEvent.Output({ result })),
328
+ const runOne = (toolkit: ToolMap, call: ToolCall): Stream.Stream<ToolEvent, never, any> =>
329
+ Match.value(toolkit[call.name]).pipe(
330
+ // No such tool. Graceful: emit a synthetic Failure so OTHER calls in this
331
+ // turn still execute. LLMs hallucinate tool names; MCP tools come and go.
332
+ Match.when(Match.undefined, () =>
333
+ outputEvent(failed(call, "unknown_tool", `No tool registered with name "${call.name}"`)),
96
334
  ),
335
+ Match.tag("LocalTool", (local) => runTool(local, call)),
336
+ // Provider/signal/interaction tools are model-visible but not locally
337
+ // executable. The loop is expected to intercept these (decode + act) before
338
+ // execution; reaching here means it didn't, so report it rather than crash.
339
+ Match.orElse(() => outputEvent(nonLocalTool(call))),
97
340
  )
98
341
 
99
- const runStreaming = <R>(
100
- tool: AnyStreamingTool<R>,
101
- call: ToolCall,
102
- ): Stream.Stream<ToolEvent, never, R> =>
342
+ const parseJsonUnknown = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
343
+
344
+ const runTool = (tool: AnyLocalTool, call: ToolCall): Stream.Stream<ToolEvent, never, any> =>
103
345
  Stream.unwrap(
104
346
  Effect.gen(function* () {
105
347
  const parsed = yield* parseJsonUnknown(call.arguments).pipe(
@@ -110,45 +352,40 @@ const runStreaming = <R>(
110
352
  catch: () => "validation_threw" as const,
111
353
  })
112
354
  if (validated.issues !== undefined) {
113
- return Stream.succeed<ToolEvent>(
114
- ToolEvent.Output({
115
- result: executionError(call, "Tool input failed schema validation"),
116
- }),
117
- )
355
+ return Stream.succeed<ToolEvent>(ToolEvent.Output({ result: validationError(call) }))
118
356
  }
119
357
 
120
- // Real-time: tap each event into a Ref as it flows; emit one
121
- // Progress per event; then concat one synthetic Output element
122
- // built from the accumulated Ref via `finalize`.
123
- const ref = yield* Ref.make<Array<unknown>>([])
124
- const progress = tool.run(validated.value).pipe(
125
- Stream.tap((event) => Ref.update(ref, Arr.append(event))),
126
- Stream.map((data) =>
127
- ToolEvent.Progress({
128
- call_id: call.call_id,
129
- tool: tool.name,
130
- data,
131
- }),
132
- ),
358
+ // Per-call queue: `emit` offers events as `run` produces them; the
359
+ // progress stream drains them in real time; `Queue.end` (in the
360
+ // `ensuring`) flushes pending events then signals a clean end, which
361
+ // `Stream.fromQueue` treats as completion. `Queue.shutdown` would clear
362
+ // queued items and interrupt pending takes — wrong for graceful teardown.
363
+ const queue = yield* Queue.make<unknown, Cause.Done>({ capacity: tool.emitBufferSize })
364
+ const emit = (event: unknown) => Effect.asVoid(Queue.offer(queue, event))
365
+ const fiber = yield* tool
366
+ .run(validated.value, emit)
367
+ .pipe(Effect.ensuring(Queue.end(queue)), Effect.forkScoped)
368
+
369
+ const progress = Stream.fromQueue(queue).pipe(
370
+ Stream.map((data) => ToolEvent.Progress({ call_id: call.call_id, tool: tool.name, data })),
133
371
  )
134
372
  const output = Stream.fromEffect(
135
- Ref.get(ref).pipe(
136
- Effect.map((events) =>
137
- ToolEvent.Output({
138
- result: okResult(call, tool.name, tool.finalize(events)),
139
- }),
373
+ 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
+ ),
140
379
  ),
141
380
  ),
142
381
  )
143
382
  return progress.pipe(Stream.concat(output))
144
383
  }),
145
384
  ).pipe(
385
+ // Backstop for the input-parsing failures (`json_parse_error`,
386
+ // `validation_threw`) and any defect before the run fiber is forked.
146
387
  Stream.catchCause(() =>
147
- Stream.succeed(
148
- ToolEvent.Output({
149
- result: executionError(call, "Tool execution failed"),
150
- }),
151
- ),
388
+ Stream.succeed(ToolEvent.Output({ result: executionError(call, "Tool execution failed") })),
152
389
  ),
153
390
  )
154
391
 
@@ -167,7 +404,7 @@ const runStreaming = <R>(
167
404
  * the results to wire-format `ToolCallOutput`s. Curried so it slots directly
168
405
  * into `continueWithResults`:
169
406
  *
170
- * Toolkit.run(tools, calls).pipe(
407
+ * Toolkit.run(toolkit, calls).pipe(
171
408
  * Toolkit.continueWithResults(Toolkit.appendToolResults(state, turn)),
172
409
  * )
173
410
  *
@@ -0,0 +1,150 @@
1
+ import { Context, type DateTime, Effect, Schema } from "effect"
2
+ import type * as AiError from "../domain/AiError.js"
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Recency
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /**
9
+ * Convenience freshness enum shared by every provider. A coarse "published
10
+ * within the last …" filter that maps onto each provider's native recency
11
+ * knob (Perplexity `search_recency_filter`, Brave `freshness`, Tavily
12
+ * `time_range`, You.com `freshness`), or onto a computed published-date
13
+ * range where a provider has no enum (Exa).
14
+ *
15
+ * For a precise window use {@link CommonSearchRequest.startDate} /
16
+ * {@link CommonSearchRequest.endDate} instead; `recency` is the sugar for
17
+ * the common "past week / past month" case.
18
+ *
19
+ * Exposed as a `Schema` so {@link "../web-search/WebSearchTool".webSearchTool}
20
+ * can hand it straight to a model as a tool parameter; the
21
+ * {@link SearchRecency} type is derived from it.
22
+ */
23
+ export const SearchRecency = Schema.Literals(["hour", "day", "week", "month", "year"])
24
+
25
+ export type SearchRecency = typeof SearchRecency.Type
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Request
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Cross-provider web-search request. A field earns a place here only if
33
+ * most of the supported providers honor it and a developer would expect it
34
+ * from a generic search interface; everything provider-specific (Exa
35
+ * `type`, Tavily `searchDepth`, Brave `goggles`, pagination `offset`,
36
+ * `safeSearch`, `topic`, …) lives on that provider's typed request.
37
+ *
38
+ * Note the one structural difference from `EmbeddingModel` /
39
+ * `Transcriber`: there is no `model`. None of the pure-`/search` endpoints
40
+ * take one - what looks model-shaped on a provider (Exa `type`, Tavily
41
+ * `searchDepth`) is a mode knob, so it stays on the provider-typed request.
42
+ */
43
+ export type CommonSearchRequest = {
44
+ /** The search query. The one field every provider requires. */
45
+ readonly query: string
46
+ /**
47
+ * Upper bound on the number of results to return in this one call.
48
+ * Providers cap differently (Perplexity / Tavily / Brave at ~20, Exa /
49
+ * You.com at ~100); a value above a provider's cap is clamped by that
50
+ * provider. There is no common pagination - only two providers paginate
51
+ * and only shallowly, so `offset` is provider-typed.
52
+ */
53
+ readonly maxResults?: number
54
+ /** Restrict results to these domains (e.g. `["docs.foo.com"]`). */
55
+ readonly includeDomains?: ReadonlyArray<string>
56
+ /** Drop results from these domains. */
57
+ readonly excludeDomains?: ReadonlyArray<string>
58
+ /**
59
+ * Coarse freshness filter - see {@link SearchRecency}. Sugar over an
60
+ * explicit `startDate`; ignored where {@link startDate} is also set and
61
+ * the provider takes only one.
62
+ */
63
+ readonly recency?: SearchRecency
64
+ /** Published-after bound. The precise alternative to {@link recency}. */
65
+ readonly startDate?: DateTime.DateTime
66
+ /** Published-before bound. */
67
+ readonly endDate?: DateTime.DateTime
68
+ /** ISO 3166-1 alpha-2 country code to localize results (e.g. `"us"`). */
69
+ readonly country?: string
70
+ /** ISO 639-1 language code to bias results toward (e.g. `"en"`). */
71
+ readonly language?: string
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Response
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
79
+ * One normalized web-search hit. A flat record with optionals rather than a
80
+ * tagged union: the providers differ by *which fields are present*, not by
81
+ * *kind*. The provider's untouched result object is always on `raw`, so a
82
+ * caller holding a typed provider tag can recover anything not promoted
83
+ * here (`author`, `favicon`, multi-chunk `snippets[]`, …).
84
+ */
85
+ export type SearchResult = {
86
+ /** Canonical result URL. Always present. */
87
+ readonly url: string
88
+ /** Result title / headline, when the provider returns one. */
89
+ readonly title?: string
90
+ /**
91
+ * Primary short excerpt - the one text field all providers return (under
92
+ * various wire names: `snippet`, `content`, `description`). The richer
93
+ * extract fields (`text`, `highlights`, `summary`) belong to the
94
+ * out-of-scope extract feature and stay on the provider-typed result.
95
+ */
96
+ readonly snippet?: string
97
+ /** Publication date, when the provider reports one. */
98
+ readonly publishedDate?: DateTime.DateTime
99
+ /**
100
+ * Relevance score, only from providers that rank (Exa, Tavily);
101
+ * `undefined` for providers that return an unscored list. Kept despite
102
+ * being non-universal because ranking is core to what a "search result"
103
+ * means - and `undefined` cleanly says "this backend doesn't score."
104
+ */
105
+ readonly score?: number
106
+ /** The provider-native result object, never lossy. */
107
+ readonly raw: unknown
108
+ }
109
+
110
+ export type SearchResponse = {
111
+ readonly results: ReadonlyArray<SearchResult>
112
+ /**
113
+ * The provider-native top-level response object. Cost / usage reporting
114
+ * is deliberately not modeled on this surface yet: providers disagree on
115
+ * both what (Exa returns USD, Tavily credits, Brave nothing in-body) and
116
+ * where (Brave reports cost in response headers), so it is deferred to the
117
+ * unified usage design (see plans/usage-tracking.md). Anything a provider
118
+ * reports survives here on `raw`.
119
+ */
120
+ readonly raw: unknown
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Service + helper
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /**
128
+ * The portable web-search surface: one operation, `search`. Pure search is
129
+ * the one thing every provider does, so - unlike `SpeechSynthesizer` /
130
+ * `Transcriber` - this capability needs no marker tags; every provider
131
+ * Layer can answer.
132
+ */
133
+ export type WebSearchService = {
134
+ readonly search: (request: CommonSearchRequest) => Effect.Effect<SearchResponse, AiError.AiError>
135
+ }
136
+
137
+ /**
138
+ * Generic web-search service tag. Yield this for provider-portable code;
139
+ * yield a provider tag (`PerplexitySearch`, `ExaSearch`, …) when you need
140
+ * that provider's own knobs. A provider's `layer` registers both.
141
+ */
142
+ export class WebSearch extends Context.Service<WebSearch, WebSearchService>()(
143
+ "@betalyra/effect-uai/WebSearch",
144
+ ) {}
145
+
146
+ /** Run one web search against whichever provider Layer is in scope. */
147
+ export const search = (
148
+ request: CommonSearchRequest,
149
+ ): Effect.Effect<SearchResponse, AiError.AiError, WebSearch> =>
150
+ Effect.flatMap(WebSearch.asEffect(), (s) => s.search(request))