@effect-uai/core 0.8.0 → 0.10.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 (165) hide show
  1. package/dist/{AiError-CAX_48RU.d.mts → AiError-Bf1pq4BB.d.mts} +9 -4
  2. package/dist/{AiError-CAX_48RU.d.mts.map → AiError-Bf1pq4BB.d.mts.map} +1 -1
  3. package/dist/{Audio-DPlTsNdz.d.mts → Audio-CR7CSljJ.d.mts} +1 -1
  4. package/dist/{Audio-DPlTsNdz.d.mts.map → Audio-CR7CSljJ.d.mts.map} +1 -1
  5. package/dist/{Image-HNmMpMTh.d.mts → Image-BZmKfIdq.d.mts} +1 -1
  6. package/dist/{Image-HNmMpMTh.d.mts.map → Image-BZmKfIdq.d.mts.map} +1 -1
  7. package/dist/{Items-7ljaVxH_.d.mts → Items-BnIduEV2.d.mts} +7 -7
  8. package/dist/{Items-7ljaVxH_.d.mts.map → Items-BnIduEV2.d.mts.map} +1 -1
  9. package/dist/Loop-D8nWpowc.d.mts +173 -0
  10. package/dist/Loop-D8nWpowc.d.mts.map +1 -0
  11. package/dist/{SandboxError-BvKypkJm.d.mts → SandboxError-CIrVDOMV.d.mts} +2 -2
  12. package/dist/{SandboxError-BvKypkJm.d.mts.map → SandboxError-CIrVDOMV.d.mts.map} +1 -1
  13. package/dist/{StructuredFormat-BbN4dosH.d.mts → StructuredFormat-DHaUJBdd.d.mts} +2 -2
  14. package/dist/{StructuredFormat-BbN4dosH.d.mts.map → StructuredFormat-DHaUJBdd.d.mts.map} +1 -1
  15. package/dist/Tool-2lC_XMth.d.mts +284 -0
  16. package/dist/Tool-2lC_XMth.d.mts.map +1 -0
  17. package/dist/ToolEvent-cWZ2BnbS.d.mts +151 -0
  18. package/dist/ToolEvent-cWZ2BnbS.d.mts.map +1 -0
  19. package/dist/ToolResult-CL4hK_9z.d.mts +157 -0
  20. package/dist/ToolResult-CL4hK_9z.d.mts.map +1 -0
  21. package/dist/Toolkit-Dr9l_hNy.d.mts +196 -0
  22. package/dist/Toolkit-Dr9l_hNy.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 +58 -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-DV5ISja1.mjs → dist-B04dlFHg.mjs} +3 -3
  36. package/dist/{dist-DV5ISja1.mjs.map → dist-B04dlFHg.mjs.map} +1 -1
  37. package/dist/dist-DWgRbDN3.mjs +140 -0
  38. package/dist/dist-DWgRbDN3.mjs.map +1 -0
  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/Image.d.mts +1 -1
  44. package/dist/domain/Items.d.mts +1 -1
  45. package/dist/domain/Music.d.mts +1 -1
  46. package/dist/domain/Turn.d.mts +4 -4
  47. package/dist/domain/Turn.test.mjs +1 -1
  48. package/dist/embedding-model/Embedding.d.mts +1 -1
  49. package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
  50. package/dist/embedding-model/EmbeddingModel.test.mjs +1 -1
  51. package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -1
  52. package/dist/index.d.mts +19 -13
  53. package/dist/index.mjs +15 -9
  54. package/dist/language-model/LanguageModel.d.mts +10 -5
  55. package/dist/language-model/LanguageModel.d.mts.map +1 -1
  56. package/dist/language-model/LanguageModel.mjs.map +1 -1
  57. package/dist/language-model/LanguageModel.test.mjs +1 -1
  58. package/dist/loop/Loop.d.mts +2 -141
  59. package/dist/loop/Loop.mjs +16 -20
  60. package/dist/loop/Loop.mjs.map +1 -1
  61. package/dist/loop/Loop.test.mjs +18 -1
  62. package/dist/loop/Loop.test.mjs.map +1 -1
  63. package/dist/{magic-string.es-BgIV5Mu3.mjs → magic-string.es-Dk-MMA0F.mjs} +1 -1
  64. package/dist/{magic-string.es-BgIV5Mu3.mjs.map → magic-string.es-Dk-MMA0F.mjs.map} +1 -1
  65. package/dist/music-generator/MusicGenerator.d.mts +2 -2
  66. package/dist/music-generator/MusicGenerator.test.mjs +1 -1
  67. package/dist/observability/Metrics.d.mts +116 -28
  68. package/dist/observability/Metrics.d.mts.map +1 -1
  69. package/dist/observability/Metrics.mjs +250 -38
  70. package/dist/observability/Metrics.mjs.map +1 -1
  71. package/dist/observability/Metrics.test.d.mts +1 -0
  72. package/dist/observability/Metrics.test.mjs +187 -0
  73. package/dist/observability/Metrics.test.mjs.map +1 -0
  74. package/dist/observability/Telemetry.d.mts +37 -0
  75. package/dist/observability/Telemetry.d.mts.map +1 -0
  76. package/dist/observability/Telemetry.mjs +57 -0
  77. package/dist/observability/Telemetry.mjs.map +1 -0
  78. package/dist/observability/Telemetry.test.d.mts +1 -0
  79. package/dist/observability/Telemetry.test.mjs +92 -0
  80. package/dist/observability/Telemetry.test.mjs.map +1 -0
  81. package/dist/retry/Retry.d.mts +2 -2
  82. package/dist/retry/Retry.test.mjs +1 -1
  83. package/dist/sandbox/Sandbox.d.mts +1 -1
  84. package/dist/sandbox/SandboxError.d.mts +1 -1
  85. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +2 -2
  86. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +1 -1
  87. package/dist/streaming/JSONL.d.mts +1 -1
  88. package/dist/streaming/JSONL.test.mjs +1 -1
  89. package/dist/streaming/SSE.test.mjs +1 -1
  90. package/dist/structured-format/StructuredFormat.d.mts +1 -1
  91. package/dist/structured-format/StructuredFormat.mjs.map +1 -1
  92. package/dist/structured-format/StructuredFormat.test.mjs +1 -1
  93. package/dist/testing/MockMusicGenerator.d.mts +1 -1
  94. package/dist/testing/MockProvider.d.mts +1 -1
  95. package/dist/testing/MockSpeechSynthesizer.d.mts +1 -1
  96. package/dist/tool/Approval.d.mts +19 -7
  97. package/dist/tool/Approval.d.mts.map +1 -1
  98. package/dist/tool/Approval.mjs +18 -11
  99. package/dist/tool/Approval.mjs.map +1 -1
  100. package/dist/tool/Approval.test.mjs +80 -40
  101. package/dist/tool/Approval.test.mjs.map +1 -1
  102. package/dist/tool/HistoryCheck.d.mts +2 -2
  103. package/dist/tool/Tool.d.mts +2 -2
  104. package/dist/tool/Tool.mjs +190 -36
  105. package/dist/tool/Tool.mjs.map +1 -1
  106. package/dist/tool/Tool.test.mjs +41 -3
  107. package/dist/tool/Tool.test.mjs.map +1 -1
  108. package/dist/tool/ToolEvent.d.mts +2 -151
  109. package/dist/tool/ToolEvent.mjs +5 -0
  110. package/dist/tool/ToolEvent.mjs.map +1 -1
  111. package/dist/tool/ToolResult.d.mts +2 -138
  112. package/dist/tool/ToolResult.mjs +32 -8
  113. package/dist/tool/ToolResult.mjs.map +1 -1
  114. package/dist/tool/Toolkit.d.mts +2 -73
  115. package/dist/tool/Toolkit.mjs +153 -35
  116. package/dist/tool/Toolkit.mjs.map +1 -1
  117. package/dist/tool/Toolkit.test.mjs +271 -11
  118. package/dist/tool/Toolkit.test.mjs.map +1 -1
  119. package/dist/transcriber/Transcriber.d.mts +2 -2
  120. package/dist/transcriber/Transcriber.test.mjs +1 -1
  121. package/dist/web-read/WebRead.d.mts +44 -0
  122. package/dist/web-read/WebRead.d.mts.map +1 -0
  123. package/dist/web-read/WebRead.mjs +18 -0
  124. package/dist/web-read/WebRead.mjs.map +1 -0
  125. package/dist/web-read/WebReadTool.d.mts +57 -0
  126. package/dist/web-read/WebReadTool.d.mts.map +1 -0
  127. package/dist/web-read/WebReadTool.mjs +59 -0
  128. package/dist/web-read/WebReadTool.mjs.map +1 -0
  129. package/dist/web-search/WebSearch.d.mts +1 -1
  130. package/dist/web-search/WebSearchTool.d.mts +12 -6
  131. package/dist/web-search/WebSearchTool.d.mts.map +1 -1
  132. package/dist/web-search/WebSearchTool.mjs +10 -5
  133. package/dist/web-search/WebSearchTool.mjs.map +1 -1
  134. package/package.json +21 -1
  135. package/src/browser/Browser.ts +268 -0
  136. package/src/browser/BrowserError.ts +145 -0
  137. package/src/browser/BrowserTool.ts +161 -0
  138. package/src/domain/AiError.ts +27 -1
  139. package/src/embedding-model/EmbeddingModel.test.ts +4 -0
  140. package/src/index.ts +6 -0
  141. package/src/language-model/LanguageModel.ts +7 -2
  142. package/src/loop/Loop.test.ts +22 -0
  143. package/src/loop/Loop.ts +27 -4
  144. package/src/observability/Metrics.test.ts +266 -0
  145. package/src/observability/Metrics.ts +505 -65
  146. package/src/observability/Telemetry.test.ts +123 -0
  147. package/src/observability/Telemetry.ts +96 -0
  148. package/src/structured-format/StructuredFormat.ts +4 -0
  149. package/src/tool/Approval.test.ts +77 -26
  150. package/src/tool/Approval.ts +72 -27
  151. package/src/tool/Tool.test.ts +55 -2
  152. package/src/tool/Tool.ts +335 -70
  153. package/src/tool/ToolEvent.ts +5 -0
  154. package/src/tool/ToolResult.ts +36 -9
  155. package/src/tool/Toolkit.test.ts +339 -9
  156. package/src/tool/Toolkit.ts +470 -120
  157. package/src/web-read/WebRead.ts +70 -0
  158. package/src/web-read/WebReadTool.ts +96 -0
  159. package/src/web-search/WebSearchTool.ts +11 -5
  160. package/dist/Tool-BKiieFXk.d.mts +0 -102
  161. package/dist/Tool-BKiieFXk.d.mts.map +0 -1
  162. package/dist/loop/Loop.d.mts.map +0 -1
  163. package/dist/tool/ToolEvent.d.mts.map +0 -1
  164. package/dist/tool/ToolResult.d.mts.map +0 -1
  165. package/dist/tool/Toolkit.d.mts.map +0 -1
@@ -1,157 +1,509 @@
1
- import { Array as Arr, Effect, Function, Ref, Schema, Stream } from "effect"
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec"
2
+ import {
3
+ Array as Arr,
4
+ Cause,
5
+ Data,
6
+ Effect,
7
+ Fiber,
8
+ Function,
9
+ Match,
10
+ Option,
11
+ Queue,
12
+ Record,
13
+ Ref,
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 DecodeResult,
23
+ decodeCallInput,
24
+ type Emit,
25
+ type InteractionTool,
26
+ type ProviderTool,
27
+ type SignalTool,
11
28
  type Tool,
29
+ type ToolDescriptor,
30
+ type ToolE,
31
+ ToolFailed,
32
+ type ToolR,
33
+ toDescriptors,
12
34
  } from "./Tool.js"
13
- import { ToolResult, executionError, failed, toToolCallOutput } from "./ToolResult.js"
35
+ import {
36
+ ToolResult,
37
+ failed,
38
+ nonLocalTool,
39
+ toToolCallOutput,
40
+ validationError,
41
+ } from "./ToolResult.js"
14
42
  import { ToolEvent } from "./ToolEvent.js"
15
43
  import { isOutput } from "./ToolEvent.js"
16
44
 
45
+ // ---------------------------------------------------------------------------
46
+ // Toolkit: a name-indexed record of tools — "what the model sees". The record
47
+ // gives O(1) dispatch and within-toolkit name-uniqueness for free; descriptors
48
+ // are derived, not stored. Build a static one with `make` (compile-time
49
+ // duplicate-name check), a dynamic one with `fromArray` (trusted single source,
50
+ // last-wins), and combine independent ones with `compose` (the application
51
+ // boundary where cross-source names can clash).
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export type ToolMap = Record<string, AnyTool>
55
+
56
+ export type Toolkit<Tools extends ToolMap = ToolMap> = Tools
57
+
58
+ const indexByName = (tools: ReadonlyArray<AnyTool>): ToolMap =>
59
+ Object.fromEntries(tools.map((tool) => [tool.name, tool]))
60
+
61
+ /**
62
+ * Index literal tools by their `name`. Duplicate names are a compile error
63
+ * (`UniqueTools`). Names are not otherwise checked: a malformed name is a
64
+ * developer typo in a literal that the provider rejects clearly on first use,
65
+ * and there is no single cross-provider name rule to enforce here anyway.
66
+ */
67
+ const buildStatic = <Tools extends ReadonlyArray<AnyTool>>(
68
+ tools: Tools,
69
+ ): Toolkit<{ [T in Tools[number] as T["name"]]: T }> =>
70
+ indexByName(tools) as Toolkit<{ [T in Tools[number] as T["name"]]: T }>
71
+
72
+ export const make = <const Tools extends ReadonlyArray<AnyTool>>(
73
+ ...tools: UniqueTools<Tools>
74
+ ): Toolkit<{ [T in Tools[number] as T["name"]]: T }> => buildStatic(tools as unknown as Tools)
75
+
76
+ /**
77
+ * Build a toolkit from a dynamically-sourced array (MCP server, plugin). Names
78
+ * are not validated (MCP legitimately uses dots / >64 chars — sanitize at the
79
+ * provider adapter); same-named tools resolve last-wins. Cross-source clashes
80
+ * are caught by `compose`.
81
+ */
82
+ export const fromArray = (tools: ReadonlyArray<AnyTool>): Toolkit => indexByName(tools)
83
+
84
+ /** Render the provider-facing descriptors for a toolkit on demand. */
85
+ export const descriptors = (toolkit: Toolkit): ReadonlyArray<ToolDescriptor> =>
86
+ toDescriptors(Object.values(toolkit))
87
+
88
+ /**
89
+ * Union of every tool's `R` requirements in a toolkit. Surfaced by `run` so the
90
+ * loop's stream type carries the services tools need through to `Effect.provide`.
91
+ *
92
+ * The `ToolMap extends T` guard yields `never` for the wide `Toolkit` type (a
93
+ * toolkit passed through an untyped boundary, where the requirements aren't
94
+ * statically known) instead of `any`, which would otherwise poison the
95
+ * resulting stream's `R`. Concrete toolkits from `make` keep their precise R.
96
+ */
97
+ export type ToolkitR<T extends Toolkit> = ToolMap extends T ? never : ToolR<T[keyof T]>
98
+
99
+ /**
100
+ * Union of every tool's `E` error type in a toolkit. `Toolkit.run` reports
101
+ * `Exclude<ToolkitE<T>, string | ToolFailed>` on its stream: the sentinel
102
+ * failures are absorbed into results, everything else propagates typed. The
103
+ * same `ToolMap extends T` guard as `ToolkitR` keeps a wide toolkit at
104
+ * `never` rather than `any`.
105
+ */
106
+ export type ToolkitE<T extends Toolkit> = ToolMap extends T ? never : ToolE<T[keyof T]>
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Uniqueness & composition. `make` rejects duplicate literal names at compile
110
+ // time; `compose` rejects cross-source collisions — at compile time when the
111
+ // names are statically known, and at runtime (`DuplicateToolName`) for dynamic
112
+ // sources whose keys are only `string`. No silent last-wins overwrite, no late
113
+ // provider 400.
114
+ // ---------------------------------------------------------------------------
115
+
116
+ // --- compile-time duplicate detection --------------------------------------
117
+
118
+ type DuplicateName<
119
+ Tools extends ReadonlyArray<AnyTool>,
120
+ Seen extends string = never,
121
+ > = Tools extends readonly [
122
+ infer Head extends AnyTool,
123
+ ...infer Tail extends ReadonlyArray<AnyTool>,
124
+ ]
125
+ ? Head["name"] extends Seen
126
+ ? Head["name"]
127
+ : DuplicateName<Tail, Seen | Head["name"]>
128
+ : never
129
+
130
+ type DuplicateNameError<Name extends string> = {
131
+ readonly __error: `Duplicate tool name: ${Name}`
132
+ }
133
+
134
+ export type NoDuplicateNames<Tools extends ReadonlyArray<AnyTool>> = [
135
+ DuplicateName<Tools>,
136
+ ] extends [never]
137
+ ? unknown
138
+ : DuplicateNameError<DuplicateName<Tools>>
139
+
140
+ /** `Tools` constrained so a repeated literal name is a compile error in `make`. */
141
+ export type UniqueTools<Tools extends ReadonlyArray<AnyTool>> = Tools & NoDuplicateNames<Tools>
142
+
143
+ // Cross-toolkit literal-key collision, skipping wide (`string`-keyed, dynamic)
144
+ // toolkits so the compile-time check never false-positives on `fromArray`.
145
+ type ComposeDuplicateKey<
146
+ Kits extends ReadonlyArray<Toolkit>,
147
+ Seen extends string = never,
148
+ > = Kits extends readonly [infer Head extends Toolkit, ...infer Tail extends ReadonlyArray<Toolkit>]
149
+ ? string extends keyof Head
150
+ ? ComposeDuplicateKey<Tail, Seen>
151
+ : Extract<keyof Head & string, Seen> extends never
152
+ ? ComposeDuplicateKey<Tail, Seen | (keyof Head & string)>
153
+ : Extract<keyof Head & string, Seen>
154
+ : never
155
+
156
+ type NoDuplicateComposedKeys<Kits extends ReadonlyArray<Toolkit>> = [
157
+ ComposeDuplicateKey<Kits>,
158
+ ] extends [never]
159
+ ? unknown
160
+ : DuplicateNameError<ComposeDuplicateKey<Kits>>
161
+
162
+ type UnionToIntersection<U> = (U extends unknown ? (x: U) => void : never) extends (
163
+ x: infer I,
164
+ ) => void
165
+ ? I
166
+ : never
167
+
168
+ type Composed<Kits extends ReadonlyArray<Toolkit>> =
169
+ UnionToIntersection<Kits[number]> extends infer M ? (M extends ToolMap ? M : ToolMap) : ToolMap
170
+
171
+ // --- uniqueness error ------------------------------------------------------
172
+
173
+ /**
174
+ * Two composed toolkits resolved to the same final name. Carries the colliding
175
+ * `sources` (the positions of the clashing toolkits) so the error says *which*
176
+ * inputs collided. Raised at runtime by `compose` for dynamic sources; static
177
+ * collisions are caught earlier at compile time.
178
+ */
179
+ export class DuplicateToolName extends Data.TaggedError("DuplicateToolName")<{
180
+ readonly name: string
181
+ readonly sources: ReadonlyArray<string>
182
+ }> {}
183
+
184
+ // --- namespacing -----------------------------------------------------------
185
+
186
+ type PrefixName<Prefix extends string, Name extends string> = `${Prefix}__${Name}`
187
+
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>
191
+ : T extends ProviderTool<infer N, infer I, infer P, infer C>
192
+ ? ProviderTool<PrefixName<Prefix, N>, I, P, C>
193
+ : T extends SignalTool<infer N, infer I>
194
+ ? SignalTool<PrefixName<Prefix, N>, I>
195
+ : T extends InteractionTool<infer N, infer I>
196
+ ? InteractionTool<PrefixName<Prefix, N>, I>
197
+ : T
198
+
199
+ type Namespaced<Prefix extends string, T extends ToolMap> = {
200
+ readonly [K in keyof T & string as PrefixName<Prefix, K>]: T[K] extends AnyTool
201
+ ? PrefixTool<Prefix, T[K]>
202
+ : never
203
+ }
204
+
205
+ /**
206
+ * Prefix every tool name with `<namespace>__`, returning a new toolkit. Use it
207
+ * to keep generic names (`search`) from two sources distinct before `compose`.
208
+ * If two namespaces collide at the `__` separator, the duplicate final name
209
+ * surfaces from `compose` as a `DuplicateToolName`.
210
+ */
211
+ export const namespace = <const Prefix extends string, const T extends ToolMap>(
212
+ prefix: Prefix,
213
+ toolkit: Toolkit<T>,
214
+ ): Toolkit<Namespaced<Prefix, T>> => {
215
+ const renamed = Arr.map(Record.values(toolkit), (tool) => ({
216
+ ...tool,
217
+ name: `${prefix}__${tool.name}`,
218
+ }))
219
+ return indexByName(renamed) as Toolkit<Namespaced<Prefix, T>>
220
+ }
221
+
222
+ /** `namespace(prefix, make(...tools))` in one step, for static namespaced tools. */
223
+ export const makeNamespaced = <
224
+ const Prefix extends string,
225
+ const Tools extends ReadonlyArray<AnyTool>,
226
+ >(
227
+ prefix: Prefix,
228
+ ...tools: UniqueTools<Tools>
229
+ ): Toolkit<Namespaced<Prefix, { [T in Tools[number] as T["name"]]: T }>> =>
230
+ namespace(prefix, buildStatic(tools as unknown as Tools))
231
+
232
+ // --- composition (the application boundary) --------------------------------
233
+
234
+ /**
235
+ * Combine independently-built toolkits into one. This is where names from
236
+ * separate sources can collide, so it is the one effectful constructor: a
237
+ * duplicate final name fails with `DuplicateToolName` (naming the colliding
238
+ * input positions) instead of silently overwriting or 400-ing later at the
239
+ * provider. Statically-known collisions are additionally a compile error.
240
+ */
241
+ export const compose = <const Kits extends ReadonlyArray<Toolkit>>(
242
+ ...toolkits: Kits & NoDuplicateComposedKeys<Kits>
243
+ ): Effect.Effect<Toolkit<Composed<Kits>>, DuplicateToolName> =>
244
+ Effect.gen(function* () {
245
+ const kits = toolkits as unknown as ReadonlyArray<Toolkit>
246
+ const labelled = Arr.flatMap(kits, (kit, i) =>
247
+ Arr.map(Record.keys(kit), (name) => ({ name, source: `toolkit-${i}` })),
248
+ )
249
+ const collision = Arr.findFirst(
250
+ Record.toEntries(Arr.groupBy(labelled, (entry) => entry.name)),
251
+ ([, group]) => group.length > 1,
252
+ )
253
+ if (Option.isSome(collision)) {
254
+ const [name, group] = collision.value
255
+ return yield* new DuplicateToolName({ name, sources: Arr.map(group, (e) => e.source) })
256
+ }
257
+ return Arr.reduce(kits, {} as ToolMap, (acc, kit) => ({ ...acc, ...kit })) as Toolkit<
258
+ Composed<Kits>
259
+ >
260
+ })
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Middleware: a `Toolkit -> Toolkit` transform applied up front, then the
264
+ // wrapped toolkit is run. A middleware transforms one local tool's `run` given
265
+ // its name (logging, retry, auth, metrics); `wrap` lifts it over the whole
266
+ // toolkit, leaving provider/signal/interaction kinds untouched. The one reason
267
+ // this is a combinator rather than native `Record.map`: it tracks the
268
+ // middleware's added requirement `R2`, unioning it into `ToolkitR` instead of
269
+ // widening every tool's type.
270
+ // ---------------------------------------------------------------------------
271
+
272
+ type Handler<Input, Event, Output, E, R> = (
273
+ input: Input,
274
+ emit: Emit<Event>,
275
+ ) => Effect.Effect<Output, E, R>
276
+
277
+ /**
278
+ * A `run` transform, tracking an added requirement `R2` and optional added
279
+ * error `E2`. The tool's own `E` is preserved; `describeFailures` is the
280
+ * combinator that *replaces* it (mapping every error to `string`).
281
+ */
282
+ export type Middleware<R2 = never, E2 = never> = <Input, Event, Output, E, R>(
283
+ run: Handler<Input, Event, Output, E, R>,
284
+ name: string,
285
+ ) => Handler<Input, Event, Output, E | E2, R | R2>
286
+
287
+ type WrapTool<T extends AnyTool, R2, E2> =
288
+ T extends Tool<infer N, infer I, infer Ev, infer O, infer E, infer R>
289
+ ? Tool<N, I, Ev, O, E | E2, R | R2>
290
+ : T
291
+
292
+ type WrapToolkit<T extends ToolMap, R2, E2> = {
293
+ readonly [K in keyof T]: T[K] extends AnyTool ? WrapTool<T[K], R2, E2> : T[K]
294
+ }
295
+
17
296
  /**
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`.
297
+ * Wrap every local tool's `run` with a middleware, preserving each tool's
298
+ * `Input`/`Output` and unioning the middleware's requirement `R2` into the
299
+ * toolkit's `ToolkitR`. Non-local kinds pass through unchanged. Stack with
300
+ * `pipe` - inner-first, so the last `wrap` applied is the outermost and runs
301
+ * first:
302
+ *
303
+ * const observed = pipe(toolkit, Toolkit.wrap(logging), Toolkit.wrap(authz))
304
+ * Toolkit.run(observed, calls) // authz runs, then logging, then the tool
305
+ *
306
+ * A middleware that fails with `string` / `ToolFailed` is absorbed by the
307
+ * executor into a `ToolResult.Failure`; any other error propagates typed. Use
308
+ * `describeFailures` for the total "everything to the model" case, or the
309
+ * `Approval` flow for a distinct "denied" verdict.
21
310
  */
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
311
+ export const wrap: {
312
+ <R2, E2>(
313
+ middleware: Middleware<R2, E2>,
314
+ ): <T extends Toolkit>(toolkit: T) => WrapToolkit<T, R2, E2>
315
+ <T extends Toolkit, R2, E2>(toolkit: T, middleware: Middleware<R2, E2>): WrapToolkit<T, R2, E2>
316
+ } = Function.dual(
317
+ 2,
318
+ <T extends Toolkit, R2, E2>(toolkit: T, middleware: Middleware<R2, E2>): WrapToolkit<T, R2, E2> =>
319
+ Record.map(toolkit, (tool) =>
320
+ tool._tag === "LocalTool" ? { ...tool, run: middleware(tool.run, tool.name) } : tool,
321
+ ) as WrapToolkit<T, R2, E2>,
322
+ )
28
323
 
29
324
  // ---------------------------------------------------------------------------
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.
325
+ // describeFailures: map every local tool's error to a model-visible string,
326
+ // opting its failures into recovery. Group tools by recovery policy, wrap
327
+ // each group, then compose.
328
+ // ---------------------------------------------------------------------------
329
+
330
+ type DescribeTool<T extends AnyTool> =
331
+ T extends Tool<infer N, infer I, infer Ev, infer O, any, infer R>
332
+ ? Tool<N, I, Ev, O, string, R>
333
+ : T
334
+
335
+ type DescribeToolkit<T extends ToolMap> = {
336
+ readonly [K in keyof T]: T[K] extends AnyTool ? DescribeTool<T[K]> : T[K]
337
+ }
338
+
339
+ /**
340
+ * Map every local tool's error to a `string`, so `Toolkit.run` absorbs those
341
+ * failures into `ToolResult.Failure` the model reads and adapts to. Pairs
342
+ * with the domain `describe` helpers (`AiError.describe`,
343
+ * `BrowserError.describe`). Non-local kinds pass through.
344
+ *
345
+ * const kit = yield* Toolkit.compose(
346
+ * Toolkit.describeFailures(searchKit, AiError.describe),
347
+ * browserToolkit(session), // failures stay typed
348
+ * )
349
+ */
350
+ export const describeFailures: {
351
+ <E>(describe: (e: E) => string): <T extends Toolkit>(toolkit: T) => DescribeToolkit<T>
352
+ <T extends Toolkit>(toolkit: T, describe: (e: ToolkitE<T>) => string): DescribeToolkit<T>
353
+ } = Function.dual(
354
+ 2,
355
+ <T extends Toolkit>(toolkit: T, describe: (e: any) => string): DescribeToolkit<T> =>
356
+ Record.map(toolkit, (tool) =>
357
+ tool._tag === "LocalTool"
358
+ ? {
359
+ ...tool,
360
+ run: (input: unknown, emit: Emit<unknown>) =>
361
+ (tool as AnyLocalTool).run(input, emit).pipe(Effect.mapError(describe)),
362
+ }
363
+ : tool,
364
+ ) as DescribeToolkit<T>,
365
+ )
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // Tool executor. Streams `ToolEvent`s in real time. Policy stays outside this
369
+ // module: callers pass only the calls they have already decided should run.
33
370
  // ---------------------------------------------------------------------------
34
371
 
35
372
  export type ExecuteOptions = {
36
373
  readonly concurrency?: number | "unbounded"
37
374
  }
38
375
 
39
- /** Execute every provided call. Approval/rejection policy belongs upstream. */
40
- export const run = <Tools extends ReadonlyArray<AnyTool<any>>>(
41
- tools: Tools,
376
+ /**
377
+ * Execute every provided call. Approval/rejection policy belongs upstream.
378
+ *
379
+ * String and `ToolFailed` failures are absorbed into `ToolResult.Failure`
380
+ * (the model reads and adapts to them); every other tool error propagates
381
+ * typed on the stream, so `Exclude<ToolkitE<T>, string | ToolFailed>` is
382
+ * what a caller catches or lets end the run. If one call in a concurrent
383
+ * batch fails the stream, siblings may go unanswered: a loop that catches
384
+ * and continues must reconcile history first (`HistoryCheck.cancelAllPending`).
385
+ */
386
+ export const run = <T extends Toolkit>(
387
+ toolkit: T,
42
388
  calls: ReadonlyArray<ToolCall>,
43
389
  options?: ExecuteOptions,
44
- ): Stream.Stream<ToolEvent, never, ToolKindR<Tools>> =>
390
+ ): Stream.Stream<ToolEvent, Exclude<ToolkitE<T>, string | ToolFailed>, ToolkitR<T>> =>
45
391
  Stream.fromIterable(calls).pipe(
46
- Stream.flatMap((call) => runOne(tools, call), {
392
+ Stream.flatMap((call) => runOne(toolkit, call), {
47
393
  concurrency: options?.concurrency ?? "unbounded",
48
394
  }),
49
- )
395
+ ) as Stream.Stream<ToolEvent, Exclude<ToolkitE<T>, string | ToolFailed>, ToolkitR<T>>
50
396
 
51
397
  const okResult = (call: ToolCall, tool: string, value: unknown): ToolResult =>
52
- ToolResult.Ok({
53
- call_id: call.call_id,
54
- tool,
55
- value,
56
- })
398
+ ToolResult.Ok({ call_id: call.call_id, tool, value })
57
399
 
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)
400
+ const outputEvent = (result: ToolResult): Stream.Stream<ToolEvent, never, any> =>
401
+ Stream.succeed(ToolEvent.Output({ result }))
402
+
403
+ const runOne = (toolkit: ToolMap, call: ToolCall): Stream.Stream<ToolEvent, unknown, any> => {
404
+ // Own-property lookup: a hallucinated call named `constructor` / `toString`
405
+ // must not resolve to a prototype method.
406
+ const tool = Object.hasOwn(toolkit, call.name) ? toolkit[call.name] : undefined
407
+ return Match.value(tool).pipe(
408
+ // No such tool. Graceful: emit a synthetic Failure so OTHER calls in this
409
+ // turn still execute. LLMs hallucinate tool names; MCP tools come and go.
410
+ Match.when(Match.undefined, () =>
411
+ outputEvent(failed(call, "unknown_tool", `No tool registered with name "${call.name}"`)),
412
+ ),
413
+ Match.tag("LocalTool", (local) => runTool(local, call)),
414
+ // Provider/signal/interaction tools are model-visible but not locally
415
+ // executable. The loop is expected to intercept these (decode + act) before
416
+ // execution; reaching here means it didn't, so report it rather than crash.
417
+ Match.orElse(() => outputEvent(nonLocalTool(call))),
418
+ )
74
419
  }
75
420
 
76
- const parseJsonUnknown = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
421
+ const describeIssues = (issues: ReadonlyArray<StandardSchemaV1.Issue>): string =>
422
+ issues
423
+ .map((issue) => {
424
+ const path = (issue.path ?? [])
425
+ .map((seg) => (typeof seg === "object" && seg !== null ? String(seg.key) : String(seg)))
426
+ .join(".")
427
+ return path === "" ? issue.message : `${path}: ${issue.message}`
428
+ })
429
+ .join("; ")
430
+ .slice(0, 500)
77
431
 
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 })),
432
+ // A decode failure is the model's contract violation: report it as a result
433
+ // (never a stream failure) so the model can correct its next call.
434
+ const runTool = (tool: AnyLocalTool, call: ToolCall): Stream.Stream<ToolEvent, unknown, any> =>
435
+ Stream.unwrap(
436
+ decodeCallInput(tool, call).pipe(
437
+ Effect.map(
438
+ Match.type<DecodeResult<unknown>>().pipe(
439
+ Match.tag("parseError", () =>
440
+ outputEvent(validationError(call, "arguments are not valid JSON")),
441
+ ),
442
+ Match.tag("invalid", ({ issues }) =>
443
+ outputEvent(validationError(call, describeIssues(issues))),
444
+ ),
445
+ Match.tag("ok", ({ input }) => runValidated(tool, call, input)),
446
+ Match.exhaustive,
447
+ ),
448
+ ),
96
449
  ),
97
450
  )
98
451
 
99
- const runStreaming = <R>(
100
- tool: AnyStreamingTool<R>,
452
+ const runValidated = (
453
+ tool: AnyLocalTool,
101
454
  call: ToolCall,
102
- ): Stream.Stream<ToolEvent, never, R> =>
455
+ input: unknown,
456
+ ): Stream.Stream<ToolEvent, unknown, any> =>
103
457
  Stream.unwrap(
104
458
  Effect.gen(function* () {
105
- const parsed = yield* parseJsonUnknown(call.arguments).pipe(
106
- Effect.mapError(() => "json_parse_error" as const),
107
- )
108
- const validated = yield* Effect.tryPromise({
109
- try: () => Promise.resolve(tool.inputSchema["~standard"].validate(parsed)),
110
- catch: () => "validation_threw" as const,
111
- })
112
- if (validated.issues !== undefined) {
113
- return Stream.succeed<ToolEvent>(
114
- ToolEvent.Output({
115
- result: executionError(call, "Tool input failed schema validation"),
116
- }),
117
- )
118
- }
119
-
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
- ),
459
+ // Per-call queue: `emit` offers events as `run` produces them; the
460
+ // progress stream drains them in real time; `Queue.end` (in the
461
+ // `ensuring`) flushes pending events then signals a clean end, which
462
+ // `Stream.fromQueue` treats as completion. `Queue.shutdown` would clear
463
+ // queued items and interrupt pending takes — wrong for graceful teardown.
464
+ const queue = yield* Queue.make<unknown, Cause.Done>({ capacity: tool.emitBufferSize })
465
+ const emit = (event: unknown) => Effect.asVoid(Queue.offer(queue, event))
466
+ const fiber = yield* tool
467
+ .run(input, emit)
468
+ .pipe(Effect.ensuring(Queue.end(queue)), Effect.forkScoped)
469
+
470
+ const progress = Stream.fromQueue(queue).pipe(
471
+ Stream.map((data) => ToolEvent.Progress({ call_id: call.call_id, tool: tool.name, data })),
133
472
  )
134
473
  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
- }),
140
- ),
474
+ Fiber.join(fiber).pipe(
475
+ Effect.matchCauseEffect({
476
+ onSuccess: (value: unknown) =>
477
+ Effect.succeed(ToolEvent.Output({ result: okResult(call, tool.name, value) })),
478
+ onFailure: (cause: Cause.Cause<unknown>) => routeCause(call, cause),
479
+ }),
141
480
  ),
142
481
  )
143
482
  return progress.pipe(Stream.concat(output))
144
483
  }),
145
- ).pipe(
146
- Stream.catchCause(() =>
147
- Stream.succeed(
148
- ToolEvent.Output({
149
- result: executionError(call, "Tool execution failed"),
150
- }),
151
- ),
152
- ),
153
484
  )
154
485
 
486
+ // A clean single string / ToolFailed failure is absorbed as a model-visible
487
+ // result; interruption, defects, and typed non-sentinel errors propagate.
488
+ const routeCause = (
489
+ call: ToolCall,
490
+ cause: Cause.Cause<unknown>,
491
+ ): Effect.Effect<ToolEvent, unknown> => {
492
+ const only = cause.reasons.length === 1 ? cause.reasons[0] : undefined
493
+ if (only !== undefined && Cause.isFailReason(only)) {
494
+ const error = only.error
495
+ if (typeof error === "string") {
496
+ return Effect.succeed(ToolEvent.Output({ result: failed(call, "tool_failed", error) }))
497
+ }
498
+ if (error instanceof ToolFailed) {
499
+ return Effect.succeed(
500
+ ToolEvent.Output({ result: failed(call, error.kind ?? "tool_failed", error.message) }),
501
+ )
502
+ }
503
+ }
504
+ return Effect.failCause(cause)
505
+ }
506
+
155
507
  // ---------------------------------------------------------------------------
156
508
  // `continueWithResults` - bridge from a `Stream<ToolEvent>` to the loop's
157
509
  // emit shape. Drains the stream to the consumer in real-time, taps every
@@ -167,7 +519,7 @@ const runStreaming = <R>(
167
519
  * the results to wire-format `ToolCallOutput`s. Curried so it slots directly
168
520
  * into `continueWithResults`:
169
521
  *
170
- * Toolkit.run(tools, calls).pipe(
522
+ * Toolkit.run(toolkit, calls).pipe(
171
523
  * Toolkit.continueWithResults(Toolkit.appendToolResults(state, turn)),
172
524
  * )
173
525
  *
@@ -218,19 +570,17 @@ export const collectResults = <E, R>(
218
570
  export const continueWithResults: {
219
571
  <S>(
220
572
  build: (results: ReadonlyArray<ToolResult>) => S,
221
- ): <R>(
222
- stream: Stream.Stream<ToolEvent, never, R>,
223
- ) => Stream.Stream<Loop.Step<ToolEvent, S>, never, R>
224
- <S, R>(
225
- stream: Stream.Stream<ToolEvent, never, R>,
573
+ ): <E, R>(stream: Stream.Stream<ToolEvent, E, R>) => Stream.Stream<Loop.Step<ToolEvent, S>, E, R>
574
+ <S, E, R>(
575
+ stream: Stream.Stream<ToolEvent, E, R>,
226
576
  build: (results: ReadonlyArray<ToolResult>) => S,
227
- ): Stream.Stream<Loop.Step<ToolEvent, S>, never, R>
577
+ ): Stream.Stream<Loop.Step<ToolEvent, S>, E, R>
228
578
  } = Function.dual(
229
579
  2,
230
- <S, R>(
231
- stream: Stream.Stream<ToolEvent, never, R>,
580
+ <S, E, R>(
581
+ stream: Stream.Stream<ToolEvent, E, R>,
232
582
  build: (results: ReadonlyArray<ToolResult>) => S,
233
- ): Stream.Stream<Loop.Step<ToolEvent, S>, never, R> =>
583
+ ): Stream.Stream<Loop.Step<ToolEvent, S>, E, R> =>
234
584
  Stream.unwrap(
235
585
  Effect.gen(function* () {
236
586
  const ref = yield* Ref.make<ReadonlyArray<ToolResult>>([])