@effect-uai/core 0.18.0 → 0.19.0-dev-20261109183959

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 (162) hide show
  1. package/README.md +4 -4
  2. package/dist/Content-D_Qfn8Ja.d.mts +48 -0
  3. package/dist/Content-D_Qfn8Ja.d.mts.map +1 -0
  4. package/dist/File-B923KvZ9.d.mts +46 -0
  5. package/dist/File-B923KvZ9.d.mts.map +1 -0
  6. package/dist/{Image-Cv6iBLTy.d.mts → Image-Bzc1H_UK.d.mts} +19 -8
  7. package/dist/Image-Bzc1H_UK.d.mts.map +1 -0
  8. package/dist/{Items-C7MyrYPX.d.mts → Items-Bvx8s9QH.d.mts} +109 -16
  9. package/dist/Items-Bvx8s9QH.d.mts.map +1 -0
  10. package/dist/{Loop-ChpaO-ya.d.mts → Loop-CeA0AelN.d.mts} +7 -6
  11. package/dist/Loop-CeA0AelN.d.mts.map +1 -0
  12. package/dist/Tool-Bkryw6-h.d.mts +206 -0
  13. package/dist/Tool-Bkryw6-h.d.mts.map +1 -0
  14. package/dist/ToolEvent-9inLx5cI.d.mts +22 -0
  15. package/dist/ToolEvent-9inLx5cI.d.mts.map +1 -0
  16. package/dist/ToolOutput-BNTIQgGB.d.mts +151 -0
  17. package/dist/ToolOutput-BNTIQgGB.d.mts.map +1 -0
  18. package/dist/Toolkit-D2VGeD5R.d.mts +212 -0
  19. package/dist/Toolkit-D2VGeD5R.d.mts.map +1 -0
  20. package/dist/browser/BrowserTool.d.mts +84 -22
  21. package/dist/browser/BrowserTool.d.mts.map +1 -1
  22. package/dist/browser/BrowserTool.mjs +5 -0
  23. package/dist/browser/BrowserTool.mjs.map +1 -1
  24. package/dist/capabilities/Capabilities.d.mts +1 -1
  25. package/dist/decision-model/Decision.test.mjs +1 -1
  26. package/dist/{dist-C8RmtoR8.mjs → dist-B5K-y4Be.mjs} +51 -32
  27. package/dist/dist-B5K-y4Be.mjs.map +1 -0
  28. package/dist/domain/File.d.mts +2 -0
  29. package/dist/domain/File.mjs +51 -0
  30. package/dist/domain/File.mjs.map +1 -0
  31. package/dist/domain/Image.d.mts +2 -2
  32. package/dist/domain/Image.mjs +12 -2
  33. package/dist/domain/Image.mjs.map +1 -1
  34. package/dist/domain/Items.d.mts +2 -2
  35. package/dist/domain/Items.mjs +65 -4
  36. package/dist/domain/Items.mjs.map +1 -1
  37. package/dist/domain/Realtime.d.mts +72 -490
  38. package/dist/domain/Realtime.d.mts.map +1 -1
  39. package/dist/domain/Research.d.mts +1 -1
  40. package/dist/domain/Transcript.test.mjs +1 -1
  41. package/dist/domain/Turn.d.mts +57 -262
  42. package/dist/domain/Turn.d.mts.map +1 -1
  43. package/dist/domain/Turn.mjs +1 -1
  44. package/dist/embedding-model/Embedding.d.mts +1 -1
  45. package/dist/image-generator/ImageGenerator.d.mts +10 -66
  46. package/dist/image-generator/ImageGenerator.d.mts.map +1 -1
  47. package/dist/index.d.mts +10 -9
  48. package/dist/index.mjs +7 -6
  49. package/dist/language-model/LanguageModel.d.mts +2 -2
  50. package/dist/loop/Loop.d.mts +1 -1
  51. package/dist/loop/Loop.mjs.map +1 -1
  52. package/dist/messenger/Messenger.d.mts +43 -289
  53. package/dist/messenger/Messenger.d.mts.map +1 -1
  54. package/dist/messenger/MessengerAdapter.test.mjs +1 -1
  55. package/dist/observability/Metrics.d.mts +1 -1
  56. package/dist/observability/Metrics.test.mjs +1 -1
  57. package/dist/observability/Telemetry.test.mjs +1 -1
  58. package/dist/realtime/RealtimeSession.d.mts +1 -1
  59. package/dist/realtime/RealtimeSession.test.mjs +1 -1
  60. package/dist/sandbox/Sandbox.d.mts +32 -237
  61. package/dist/sandbox/Sandbox.d.mts.map +1 -1
  62. package/dist/streaming/Multipart.mjs +2 -1
  63. package/dist/streaming/Multipart.mjs.map +1 -1
  64. package/dist/streaming/Multipart.test.mjs +28 -0
  65. package/dist/streaming/Multipart.test.mjs.map +1 -0
  66. package/dist/streaming/Settle.test.mjs +1 -1
  67. package/dist/streaming/WebSocketSession.test.mjs +1 -1
  68. package/dist/testing/MockMessenger.d.mts +19 -134
  69. package/dist/testing/MockMessenger.d.mts.map +1 -1
  70. package/dist/testing/MockProvider.d.mts +1 -1
  71. package/dist/testing/MockSandbox.d.mts +65 -446
  72. package/dist/testing/MockSandbox.d.mts.map +1 -1
  73. package/dist/tool/Content.d.mts +2 -0
  74. package/dist/tool/Content.mjs +46 -0
  75. package/dist/tool/Content.mjs.map +1 -0
  76. package/dist/tool/Content.test.d.mts +1 -0
  77. package/dist/tool/Content.test.mjs +48 -0
  78. package/dist/tool/Content.test.mjs.map +1 -0
  79. package/dist/tool/HistoryCheck.d.mts +6 -15
  80. package/dist/tool/HistoryCheck.d.mts.map +1 -1
  81. package/dist/tool/HistoryCheck.mjs +10 -24
  82. package/dist/tool/HistoryCheck.mjs.map +1 -1
  83. package/dist/tool/Tool.d.mts +2 -2
  84. package/dist/tool/Tool.mjs +55 -130
  85. package/dist/tool/Tool.mjs.map +1 -1
  86. package/dist/tool/Tool.test.mjs +220 -176
  87. package/dist/tool/Tool.test.mjs.map +1 -1
  88. package/dist/tool/ToolDecision.d.mts +164 -0
  89. package/dist/tool/ToolDecision.d.mts.map +1 -0
  90. package/dist/tool/ToolDecision.mjs +146 -0
  91. package/dist/tool/ToolDecision.mjs.map +1 -0
  92. package/dist/tool/ToolDecision.test.d.mts +1 -0
  93. package/dist/tool/ToolDecision.test.mjs +330 -0
  94. package/dist/tool/ToolDecision.test.mjs.map +1 -0
  95. package/dist/tool/ToolEvent.d.mts +2 -2
  96. package/dist/tool/ToolEvent.mjs +2 -34
  97. package/dist/tool/ToolEvent.mjs.map +1 -1
  98. package/dist/tool/ToolOutput.d.mts +2 -0
  99. package/dist/tool/ToolOutput.mjs +128 -0
  100. package/dist/tool/ToolOutput.mjs.map +1 -0
  101. package/dist/tool/ToolOutput.test.d.mts +1 -0
  102. package/dist/tool/ToolOutput.test.mjs +153 -0
  103. package/dist/tool/ToolOutput.test.mjs.map +1 -0
  104. package/dist/tool/Toolkit.d.mts +2 -2
  105. package/dist/tool/Toolkit.mjs +230 -165
  106. package/dist/tool/Toolkit.mjs.map +1 -1
  107. package/dist/tool/Toolkit.test.mjs +431 -389
  108. package/dist/tool/Toolkit.test.mjs.map +1 -1
  109. package/dist/web-read/WebReadTool.d.mts +2 -2
  110. package/dist/web-read/WebReadTool.mjs +1 -0
  111. package/dist/web-read/WebReadTool.mjs.map +1 -1
  112. package/dist/web-search/WebSearchTool.d.mts +2 -2
  113. package/dist/web-search/WebSearchTool.mjs +1 -0
  114. package/dist/web-search/WebSearchTool.mjs.map +1 -1
  115. package/package.json +17 -9
  116. package/src/browser/BrowserTool.ts +5 -0
  117. package/src/domain/File.ts +79 -0
  118. package/src/domain/Image.ts +14 -4
  119. package/src/domain/Items.ts +39 -3
  120. package/src/index.ts +3 -2
  121. package/src/loop/Loop.ts +24 -13
  122. package/src/streaming/Multipart.test.ts +26 -0
  123. package/src/streaming/Multipart.ts +5 -4
  124. package/src/tool/Content.test.ts +40 -0
  125. package/src/tool/Content.ts +38 -0
  126. package/src/tool/HistoryCheck.ts +21 -26
  127. package/src/tool/Tool.test.ts +240 -241
  128. package/src/tool/Tool.ts +203 -308
  129. package/src/tool/ToolDecision.test.ts +301 -0
  130. package/src/tool/ToolDecision.ts +360 -0
  131. package/src/tool/ToolEvent.ts +21 -41
  132. package/src/tool/ToolOutput.test.ts +132 -0
  133. package/src/tool/ToolOutput.ts +198 -0
  134. package/src/tool/Toolkit.test.ts +594 -454
  135. package/src/tool/Toolkit.ts +631 -356
  136. package/src/web-read/WebReadTool.ts +2 -1
  137. package/src/web-search/WebSearchTool.ts +2 -1
  138. package/dist/Image-Cv6iBLTy.d.mts.map +0 -1
  139. package/dist/Items-C7MyrYPX.d.mts.map +0 -1
  140. package/dist/Loop-ChpaO-ya.d.mts.map +0 -1
  141. package/dist/Tool-BF78QWtn.d.mts +0 -314
  142. package/dist/Tool-BF78QWtn.d.mts.map +0 -1
  143. package/dist/ToolEvent-Di4a723B.d.mts +0 -144
  144. package/dist/ToolEvent-Di4a723B.d.mts.map +0 -1
  145. package/dist/ToolResult-BsYQRjmW.d.mts +0 -152
  146. package/dist/ToolResult-BsYQRjmW.d.mts.map +0 -1
  147. package/dist/Toolkit-tlL0F2YR.d.mts +0 -193
  148. package/dist/Toolkit-tlL0F2YR.d.mts.map +0 -1
  149. package/dist/dist-C8RmtoR8.mjs.map +0 -1
  150. package/dist/tool/Approval.d.mts +0 -113
  151. package/dist/tool/Approval.d.mts.map +0 -1
  152. package/dist/tool/Approval.mjs +0 -80
  153. package/dist/tool/Approval.mjs.map +0 -1
  154. package/dist/tool/Approval.test.mjs +0 -371
  155. package/dist/tool/Approval.test.mjs.map +0 -1
  156. package/dist/tool/ToolResult.d.mts +0 -2
  157. package/dist/tool/ToolResult.mjs +0 -85
  158. package/dist/tool/ToolResult.mjs.map +0 -1
  159. package/src/tool/Approval.test.ts +0 -400
  160. package/src/tool/Approval.ts +0 -181
  161. package/src/tool/ToolResult.ts +0 -114
  162. /package/dist/{tool/Approval.test.d.mts → streaming/Multipart.test.d.mts} +0 -0
@@ -4,117 +4,94 @@ import {
4
4
  Cause,
5
5
  Data,
6
6
  Effect,
7
- Fiber,
8
7
  Function,
9
8
  Match,
10
9
  Option,
11
- Queue,
10
+ Predicate,
12
11
  Record,
13
12
  Ref,
13
+ Result,
14
+ Schema,
14
15
  Stream,
15
16
  } from "effect"
17
+ import type { HistoryItem, ToolCall, ToolCallOutput } from "../domain/Items.js"
18
+ import { appendToHistory, getToolCalls, type Turn } from "../domain/Turn.js"
16
19
  import * as Loop from "../loop/Loop.js"
17
- import type { HistoryItem, ToolCall } from "../domain/Items.js"
18
- import { appendToHistory, type Turn } from "../domain/Turn.js"
20
+ import * as Content from "./Content.js"
19
21
  import {
22
+ type AnyDescribed,
20
23
  type AnyLocalTool,
21
24
  type AnyTool,
22
25
  type DecodeResult,
23
26
  decodeCallInput,
24
- type Emit,
25
- type InteractionTool,
26
27
  type ProviderTool,
27
- type SignalTool,
28
28
  type Tool,
29
29
  type ToolDescriptor,
30
- type ToolE,
31
30
  ToolFailed,
31
+ type ToolFailure,
32
+ type ToolOutput as OutputOf,
32
33
  type ToolR,
34
+ type ToolUnhandled,
35
+ isStreamed,
33
36
  toDescriptors,
37
+ toStream,
38
+ withFailures as withToolFailures,
34
39
  } from "./Tool.js"
35
- import {
36
- ToolResult,
37
- failed,
38
- nonLocalTool,
39
- toToolCallOutput,
40
- validationError,
41
- } from "./ToolResult.js"
42
- import { ToolEvent } from "./ToolEvent.js"
43
- import { isOutput } from "./ToolEvent.js"
40
+ import type * as ToolEvent from "./ToolEvent.js"
41
+ import * as ToolOutput from "./ToolOutput.js"
42
+ import { CoreError, InputInvalid, OutputInvalid, type RunError, UnknownTool } from "./ToolOutput.js"
44
43
 
45
44
  // ---------------------------------------------------------------------------
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).
45
+ // Toolkit: a name-indexed record of tools, what the model sees. `make` checks
46
+ // duplicate names at compile time, `fromArray` takes a dynamic source
47
+ // (last wins), `compose` joins independent toolkits and fails on clashes.
52
48
  // ---------------------------------------------------------------------------
53
49
 
54
50
  export type ToolMap = Record<string, AnyTool>
55
51
 
56
52
  export type Toolkit<Tools extends ToolMap = ToolMap> = Tools
57
53
 
54
+ type Indexed<Tools extends ReadonlyArray<AnyTool>> = { [T in Tools[number] as T["name"]]: T }
55
+
58
56
  const indexByName = (tools: ReadonlyArray<AnyTool>): ToolMap =>
59
57
  Object.fromEntries(tools.map((tool) => [tool.name, tool]))
60
58
 
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 }>
59
+ const buildStatic = <Tools extends ReadonlyArray<AnyTool>>(tools: Tools): Toolkit<Indexed<Tools>> =>
60
+ indexByName(tools) as Toolkit<Indexed<Tools>>
71
61
 
62
+ /** Index tools by `name`. A repeated literal name is a compile error. */
72
63
  export const make = <const Tools extends ReadonlyArray<AnyTool>>(
73
64
  ...tools: UniqueTools<Tools>
74
- ): Toolkit<{ [T in Tools[number] as T["name"]]: T }> => buildStatic(tools as unknown as Tools)
65
+ ): Toolkit<Indexed<Tools>> => buildStatic<Tools>(tools)
75
66
 
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
- */
67
+ /** A toolkit from a dynamic source (MCP server, plugin). Same-named tools: last wins. */
82
68
  export const fromArray = (tools: ReadonlyArray<AnyTool>): Toolkit => indexByName(tools)
83
69
 
84
- /** Render the provider-facing descriptors for a toolkit on demand. */
85
70
  export const descriptors = (toolkit: Toolkit): ReadonlyArray<ToolDescriptor> =>
86
71
  toDescriptors(Object.values(toolkit))
87
72
 
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
- */
73
+ // A wide `Toolkit` (types unknown statically) yields `never`, not `any`.
74
+ type Locals<T extends Toolkit> = Extract<T[keyof T], { readonly _tag: "LocalTool" }>
75
+
76
+ /** Every tool's requirements. */
97
77
  export type ToolkitR<T extends Toolkit> = ToolMap extends T ? never : ToolR<T[keyof T]>
98
78
 
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]>
79
+ /** Every error a tool did not declare in `failureSchema`: these fail `run`'s stream. */
80
+ export type ToolkitE<T extends Toolkit> = ToolMap extends T ? never : ToolUnhandled<T[keyof T]>
81
+
82
+ /** What `run` emits: one member per local tool, plus calls it could not run at all. */
83
+ export type Event<T extends Toolkit> =
84
+ | (Locals<T> extends infer X
85
+ ? X extends Tool<infer N, any, infer O, infer F, any, any>
86
+ ? ToolEvent.ToolEvent<N, O, F | RunError>
87
+ : never
88
+ : never)
89
+ | ToolEvent.Output<string, never, RunError>
107
90
 
108
91
  // ---------------------------------------------------------------------------
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.
92
+ // Uniqueness and composition
114
93
  // ---------------------------------------------------------------------------
115
94
 
116
- // --- compile-time duplicate detection --------------------------------------
117
-
118
95
  type DuplicateName<
119
96
  Tools extends ReadonlyArray<AnyTool>,
120
97
  Seen extends string = never,
@@ -137,11 +114,9 @@ export type NoDuplicateNames<Tools extends ReadonlyArray<AnyTool>> = [
137
114
  ? unknown
138
115
  : DuplicateNameError<DuplicateName<Tools>>
139
116
 
140
- /** `Tools` constrained so a repeated literal name is a compile error in `make`. */
141
117
  export type UniqueTools<Tools extends ReadonlyArray<AnyTool>> = Tools & NoDuplicateNames<Tools>
142
118
 
143
- // Cross-toolkit literal-key collision, skipping wide (`string`-keyed, dynamic)
144
- // toolkits so the compile-time check never false-positives on `fromArray`.
119
+ // Skips wide (`string`-keyed) toolkits so `fromArray` never false-positives.
145
120
  type ComposeDuplicateKey<
146
121
  Kits extends ReadonlyArray<Toolkit>,
147
122
  Seen extends string = never,
@@ -168,33 +143,20 @@ type UnionToIntersection<U> = (U extends unknown ? (x: U) => void : never) exten
168
143
  type Composed<Kits extends ReadonlyArray<Toolkit>> =
169
144
  UnionToIntersection<Kits[number]> extends infer M ? (M extends ToolMap ? M : ToolMap) : ToolMap
170
145
 
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
- */
146
+ /** Two composed toolkits share a final name; `sources` are their positions. */
179
147
  export class DuplicateToolName extends Data.TaggedError("DuplicateToolName")<{
180
148
  readonly name: string
181
149
  readonly sources: ReadonlyArray<string>
182
150
  }> {}
183
151
 
184
- // --- namespacing -----------------------------------------------------------
185
-
186
152
  type PrefixName<Prefix extends string, Name extends string> = `${Prefix}__${Name}`
187
153
 
188
154
  type PrefixTool<Prefix extends string, T extends AnyTool> =
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>
155
+ T extends Tool<infer N, infer I, infer O, infer F, infer E, infer R>
156
+ ? Tool<PrefixName<Prefix, N>, I, O, F, E, R>
191
157
  : T extends ProviderTool<infer N, infer I, infer P, infer C>
192
158
  ? 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
159
+ : T
198
160
 
199
161
  type Namespaced<Prefix extends string, T extends ToolMap> = {
200
162
  readonly [K in keyof T & string as PrefixName<Prefix, K>]: T[K] extends AnyTool
@@ -202,111 +164,84 @@ type Namespaced<Prefix extends string, T extends ToolMap> = {
202
164
  : never
203
165
  }
204
166
 
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
- */
167
+ /** Prefix every tool name with `<namespace>__`, e.g. to keep two `search` tools apart. */
211
168
  export const namespace = <const Prefix extends string, const T extends ToolMap>(
212
169
  prefix: Prefix,
213
170
  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
- }
171
+ ): Toolkit<Namespaced<Prefix, T>> =>
172
+ indexByName(
173
+ Arr.map(Record.values(toolkit), (tool) => ({ ...tool, name: `${prefix}__${tool.name}` })),
174
+ ) as Toolkit<Namespaced<Prefix, T>>
221
175
 
222
- /** `namespace(prefix, make(...tools))` in one step, for static namespaced tools. */
176
+ /** `namespace(prefix, make(...tools))` in one step. */
223
177
  export const makeNamespaced = <
224
178
  const Prefix extends string,
225
179
  const Tools extends ReadonlyArray<AnyTool>,
226
180
  >(
227
181
  prefix: Prefix,
228
182
  ...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) --------------------------------
183
+ ): Toolkit<Namespaced<Prefix, Indexed<Tools>>> => namespace(prefix, buildStatic<Tools>(tools))
233
184
 
234
185
  /**
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.
186
+ * Join independently built toolkits. A duplicate final name fails with
187
+ * `DuplicateToolName`; statically known duplicates are a compile error too.
240
188
  */
241
189
  export const compose = <const Kits extends ReadonlyArray<Toolkit>>(
242
190
  ...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
- })
191
+ ): Effect.Effect<Toolkit<Composed<Kits>>, DuplicateToolName> => {
192
+ const kits: ReadonlyArray<Toolkit> = toolkits
193
+ const labelled = Arr.flatMap(kits, (kit, i) =>
194
+ Arr.map(Record.keys(kit), (name) => ({ name, source: `toolkit-${i}` })),
195
+ )
196
+ return Arr.findFirst(
197
+ Record.toEntries(Arr.groupBy(labelled, (entry) => entry.name)),
198
+ ([, group]) => group.length > 1,
199
+ ).pipe(
200
+ Option.match({
201
+ onNone: () =>
202
+ Effect.succeed(
203
+ Arr.reduce(kits, {}, (acc: ToolMap, kit) => ({ ...acc, ...kit })) as Toolkit<
204
+ Composed<Kits>
205
+ >,
206
+ ),
207
+ onSome: ([name, group]) =>
208
+ Effect.fail(new DuplicateToolName({ name, sources: Arr.map(group, (e) => e.source) })),
209
+ }),
210
+ )
211
+ }
261
212
 
262
213
  // ---------------------------------------------------------------------------
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.
214
+ // Middleware: transforms each local tool's `run`, seen as a stream (an `Effect`
215
+ // run is a one-element stream), and tracks the added `R2` / `E2`.
270
216
  // ---------------------------------------------------------------------------
271
217
 
272
- type Handler<Input, Event, Output, E, R> = (
273
- input: Input,
274
- emit: Emit<Event>,
275
- ) => Effect.Effect<Output, E, R>
218
+ export type Handler<Input, Output, E, R> = (input: Input) => Stream.Stream<Output, E, R>
276
219
 
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>,
220
+ export type Middleware<R2 = never, E2 = never> = <Input, Output, E, R>(
221
+ run: Handler<Input, Output, E, R>,
284
222
  name: string,
285
- ) => Handler<Input, Event, Output, E | E2, R | R2>
223
+ ) => Handler<Input, Output, E | E2, R | R2>
286
224
 
287
225
  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>
226
+ T extends Tool<infer N, infer I, infer O, infer F, infer E, infer R>
227
+ ? Tool<N, I, O, F, E | E2, R | R2>
290
228
  : T
291
229
 
292
230
  type WrapToolkit<T extends ToolMap, R2, E2> = {
293
231
  readonly [K in keyof T]: T[K] extends AnyTool ? WrapTool<T[K], R2, E2> : T[K]
294
232
  }
295
233
 
234
+ const mapLocal = <T extends Toolkit, Out>(
235
+ toolkit: T,
236
+ f: (tool: AnyLocalTool) => AnyLocalTool,
237
+ ): Out => Record.map(toolkit, (tool) => (tool._tag === "LocalTool" ? f(tool) : tool)) as Out
238
+
296
239
  /**
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:
240
+ * Wrap every local tool's `run`. Stacks with `pipe`, inner first: the last
241
+ * `wrap` runs first. The middleware sees a stream; an `Effect` tool stays one,
242
+ * with the stream's last value.
302
243
  *
303
244
  * 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.
310
245
  */
311
246
  export const wrap: {
312
247
  <R2, E2>(
@@ -316,57 +251,110 @@ export const wrap: {
316
251
  } = Function.dual(
317
252
  2,
318
253
  <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>,
254
+ mapLocal(toolkit, (tool) => {
255
+ const wrapped = middleware((input: unknown) => toStream(tool.run(input)), tool.name)
256
+ // Keeps the shape: an `Effect` tool stays single-valued, so it emits no partials.
257
+ return {
258
+ ...tool,
259
+ run: (input: unknown) =>
260
+ isStreamed(tool.run(input)) ? wrapped(input) : lastOf(tool.name, wrapped(input)),
261
+ }
262
+ }),
322
263
  )
323
264
 
324
- // ---------------------------------------------------------------------------
325
- // describeFailures: map every local tool's error to a model-visible string,
326
- // opting its failures into recovery. Group tools by recovery policy, wrap
327
- // each group, then compose.
328
- // ---------------------------------------------------------------------------
265
+ const lastOf = <A, E, R>(name: string, values: Stream.Stream<A, E, R>): Effect.Effect<A, E, R> =>
266
+ Effect.flatMap(
267
+ Stream.runLast(values),
268
+ Option.match({
269
+ onSome: Effect.succeed,
270
+ onNone: () => Effect.die(`A middleware left the Effect tool "${name}" without a value`),
271
+ }),
272
+ )
329
273
 
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>
274
+ type AddFailures<T extends AnyTool, F2> =
275
+ T extends Tool<infer N, infer I, infer O, infer F, infer E, infer R>
276
+ ? Tool<N, I, O, F | F2, E, R>
333
277
  : T
334
278
 
335
- type DescribeToolkit<T extends ToolMap> = {
336
- readonly [K in keyof T]: T[K] extends AnyTool ? DescribeTool<T[K]> : T[K]
279
+ type AddFailuresToolkit<T extends ToolMap, F2> = {
280
+ readonly [K in keyof T]: T[K] extends AnyTool ? AddFailures<T[K], F2> : T[K]
337
281
  }
338
282
 
339
283
  /**
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
- * )
284
+ * `Tool.withFailures` for every local tool, e.g. a whole MCP toolkit that needs
285
+ * approval. Prefer it per tool when only some tools are gated.
286
+ */
287
+ export const withFailures: {
288
+ <F2, F2Encoded extends Schema.Json>(
289
+ failures: Schema.Codec<F2, F2Encoded>,
290
+ ): <T extends Toolkit>(toolkit: T) => AddFailuresToolkit<T, F2>
291
+ <T extends Toolkit, F2, F2Encoded extends Schema.Json>(
292
+ toolkit: T,
293
+ failures: Schema.Codec<F2, F2Encoded>,
294
+ ): AddFailuresToolkit<T, F2>
295
+ } = Function.dual(
296
+ 2,
297
+ <T extends Toolkit, F2>(
298
+ toolkit: T,
299
+ failures: Schema.Codec<F2, Schema.Json>,
300
+ ): AddFailuresToolkit<T, F2> => mapLocal(toolkit, (tool) => withToolFailures(tool, failures)),
301
+ )
302
+
303
+ // `D` is what `describe` returns: with `undefined` possible, some errors stay on the stream.
304
+ type DescribeTool<T extends AnyTool, D> =
305
+ T extends Tool<infer N, infer I, infer O, infer F, infer E, infer R>
306
+ ? Tool<N, I, O, F | ToolFailed, F | ToolFailed | (undefined extends D ? E : never), R>
307
+ : T
308
+
309
+ type DescribeToolkit<T extends ToolMap, D> = {
310
+ readonly [K in keyof T]: T[K] extends AnyTool ? DescribeTool<T[K], D> : T[K]
311
+ }
312
+
313
+ /**
314
+ * Undeclared errors become a `ToolFailed` the model reads, e.g. with
315
+ * `AiError.describe`. Declared failures pass through unchanged. Return
316
+ * `undefined` for an error that should still fail the run (a dead session).
349
317
  */
350
318
  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>
319
+ <E, D extends string | undefined>(
320
+ describe: (e: E) => D,
321
+ ): <T extends Toolkit>(toolkit: T) => DescribeToolkit<T, D>
322
+ <T extends Toolkit, D extends string | undefined>(
323
+ toolkit: T,
324
+ describe: (e: ToolkitE<T>) => D,
325
+ ): DescribeToolkit<T, D>
353
326
  } = Function.dual(
354
327
  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>,
328
+ <T extends Toolkit>(
329
+ toolkit: T,
330
+ describe: (e: unknown) => string | undefined,
331
+ ): DescribeToolkit<T, string | undefined> =>
332
+ mapLocal(toolkit, (tool) => {
333
+ const isDeclared = isDeclaredBy(tool)
334
+ const toFailed = (e: unknown) =>
335
+ isDeclared(e)
336
+ ? e
337
+ : Option.match(Option.fromNullishOr(describe(e)), {
338
+ onNone: () => e,
339
+ onSome: (message) => new ToolFailed({ message }),
340
+ })
341
+ return {
342
+ ...tool,
343
+ failureSchema:
344
+ tool.failureSchema === undefined
345
+ ? ToolFailed
346
+ : Schema.Union([tool.failureSchema, ToolFailed]),
347
+ // Keeps the shape: an `Effect` tool must not start emitting partials.
348
+ run: (input: unknown) => {
349
+ const out = tool.run(input)
350
+ return isStreamed(out) ? Stream.mapError(out, toFailed) : Effect.mapError(out, toFailed)
351
+ },
352
+ }
353
+ }),
365
354
  )
366
355
 
367
356
  // ---------------------------------------------------------------------------
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.
357
+ // Executor
370
358
  // ---------------------------------------------------------------------------
371
359
 
372
360
  export type ExecuteOptions = {
@@ -374,49 +362,60 @@ export type ExecuteOptions = {
374
362
  }
375
363
 
376
364
  /**
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`).
365
+ * Run the calls, emitting each one's partials and its `Output`, typed per tool
366
+ * and decoded. An error matching the tool's `failureSchema` becomes a `Failure`;
367
+ * any other error fails the stream (siblings of a failed batch may then stay
368
+ * unanswered: see `HistoryCheck.cancelAllPending`). Which calls run is the caller's call.
385
369
  */
386
370
  export const run = <T extends Toolkit>(
387
371
  toolkit: T,
388
372
  calls: ReadonlyArray<ToolCall>,
389
373
  options?: ExecuteOptions,
390
- ): Stream.Stream<ToolEvent, Exclude<ToolkitE<T>, string | ToolFailed>, ToolkitR<T>> =>
374
+ ): Stream.Stream<Event<T>, ToolkitE<T>, ToolkitR<T>> =>
391
375
  Stream.fromIterable(calls).pipe(
392
376
  Stream.flatMap((call) => runOne(toolkit, call), {
393
377
  concurrency: options?.concurrency ?? "unbounded",
394
378
  }),
395
- ) as Stream.Stream<ToolEvent, Exclude<ToolkitE<T>, string | ToolFailed>, ToolkitR<T>>
379
+ ) as Stream.Stream<Event<T>, ToolkitE<T>, ToolkitR<T>>
396
380
 
397
- const okResult = (call: ToolCall, tool: string, value: unknown): ToolResult =>
398
- ToolResult.Ok({ call_id: call.call_id, tool, value })
381
+ type AnyEvent = ToolEvent.ToolEvent<string, unknown, unknown>
399
382
 
400
- const outputEvent = (result: ToolResult): Stream.Stream<ToolEvent, never, any> =>
401
- Stream.succeed(ToolEvent.Output({ result }))
383
+ // Own-property lookup: a call named `constructor` must not hit the prototype.
384
+ const lookup = (toolkit: Toolkit, name: string): AnyTool | undefined =>
385
+ Object.hasOwn(toolkit, name) ? toolkit[name] : undefined
402
386
 
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.
387
+ // A tool whose schemas apply to the call's outcome: `Tool` or `Declared`.
388
+ const local = (toolkit: Toolkit, name: string): AnyDescribed | undefined => {
389
+ const tool = lookup(toolkit, name)
390
+ return tool === undefined || tool._tag === "ProviderTool" ? undefined : tool
391
+ }
392
+
393
+ const done = (call: ToolCall, result: Result.Result<unknown, unknown>): AnyEvent => ({
394
+ _tag: "Output",
395
+ call_id: call.call_id,
396
+ tool: call.name,
397
+ result,
398
+ })
399
+
400
+ const failWith = (call: ToolCall, error: RunError): Stream.Stream<AnyEvent> =>
401
+ Stream.succeed(done(call, Result.fail(error)))
402
+
403
+ const runOne = (toolkit: Toolkit, call: ToolCall): Stream.Stream<AnyEvent, unknown, any> =>
404
+ Match.value(lookup(toolkit, call.name)).pipe(
410
405
  Match.when(Match.undefined, () =>
411
- outputEvent(failed(call, "unknown_tool", `No tool registered with name "${call.name}"`)),
406
+ failWith(call, new UnknownTool({ message: `No tool named "${call.name}"` })),
412
407
  ),
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))),
408
+ Match.discriminatorsExhaustive("_tag")({
409
+ LocalTool: (tool) => runTool(tool, call),
410
+ DeclaredTool: () =>
411
+ failWith(
412
+ call,
413
+ new UnknownTool({ message: `"${call.name}" has no run; the app handles its calls` }),
414
+ ),
415
+ ProviderTool: () =>
416
+ failWith(call, new UnknownTool({ message: `"${call.name}" is run by the provider` })),
417
+ }),
418
418
  )
419
- }
420
419
 
421
420
  const describeIssues = (issues: ReadonlyArray<StandardSchemaV1.Issue>): string =>
422
421
  issues
@@ -429,171 +428,447 @@ const describeIssues = (issues: ReadonlyArray<StandardSchemaV1.Issue>): string =
429
428
  .join("; ")
430
429
  .slice(0, 500)
431
430
 
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
- ),
431
+ // A call's input, or the `InputInvalid` the model gets for it.
432
+ const decodeInput = (
433
+ tool: AnyDescribed,
434
+ call: ToolCall,
435
+ ): Effect.Effect<Result.Result<unknown, InputInvalid>> =>
436
+ Effect.map(
437
+ decodeCallInput(tool, call),
438
+ Match.type<DecodeResult<unknown>>().pipe(
439
+ Match.tag("parseError", () =>
440
+ Result.fail(new InputInvalid({ message: "arguments are not valid JSON" })),
441
+ ),
442
+ Match.tag("invalid", ({ issues }) =>
443
+ Result.fail(new InputInvalid({ message: describeIssues(issues) })),
448
444
  ),
445
+ Match.tag("ok", ({ input }) => Result.succeed(input)),
446
+ Match.exhaustive,
447
+ ),
448
+ )
449
+
450
+ const runTool = (tool: AnyLocalTool, call: ToolCall): Stream.Stream<AnyEvent, unknown, any> =>
451
+ Stream.unwrap(
452
+ Effect.map(
453
+ decodeInput(tool, call),
454
+ Result.match({
455
+ onFailure: (error) => failWith(call, error),
456
+ onSuccess: (input) => execute(tool, call, input),
457
+ }),
449
458
  ),
450
459
  )
451
460
 
452
- const runValidated = (
461
+ // An `Effect` tool emits its `Output`. A `Stream` tool emits each value as a
462
+ // `Partial` as it arrives, then the last one again as the `Output`. Each value
463
+ // is checked against `outputSchema` and handed on decoded.
464
+ const execute = (
453
465
  tool: AnyLocalTool,
454
466
  call: ToolCall,
455
467
  input: unknown,
456
- ): Stream.Stream<ToolEvent, unknown, any> =>
457
- Stream.unwrap(
458
- Effect.gen(function* () {
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 })),
472
- )
473
- const output = Stream.fromEffect(
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
- }),
468
+ ): Stream.Stream<AnyEvent, unknown, any> => {
469
+ const out = tool.run(input)
470
+ const events = isStreamed(out)
471
+ ? streamed(tool, call, out)
472
+ : Stream.fromEffect(
473
+ Effect.map(
474
+ Effect.tap(out, (value) => check(tool, value)),
475
+ (value) => done(call, Result.succeed(value)),
480
476
  ),
481
477
  )
482
- return progress.pipe(Stream.concat(output))
483
- }),
478
+ return Stream.catchCause(events, (cause) =>
479
+ Stream.fromEffect(Effect.map(declared(tool, cause), (error) => done(call, Result.fail(error)))),
484
480
  )
481
+ }
485
482
 
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 = (
483
+ const streamed = (
484
+ tool: AnyLocalTool,
489
485
  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) }),
486
+ values: Stream.Stream<unknown, unknown, unknown>,
487
+ ): Stream.Stream<AnyEvent, unknown, unknown> =>
488
+ values.pipe(
489
+ Stream.mapEffect((value) => Effect.as(check(tool, value), Option.some(value))),
490
+ // `None` marks a clean end: `concat` never runs after a failure, so a
491
+ // failing tool can't have its last partial promoted to a `Success`.
492
+ Stream.concat(Stream.succeed(Option.none<unknown>())),
493
+ Stream.mapAccum(
494
+ () => Option.none<unknown>(),
495
+ (last, next): readonly [Option.Option<unknown>, ReadonlyArray<AnyEvent>] =>
496
+ Option.match(next, {
497
+ onSome: (value) => [
498
+ Option.some(value),
499
+ [{ _tag: "Partial", call_id: call.call_id, tool: tool.name, value }],
500
+ ],
501
+ onNone: () => [
502
+ last,
503
+ [
504
+ done(
505
+ call,
506
+ Option.match(last, {
507
+ onNone: () =>
508
+ Result.fail(new OutputInvalid({ message: "The tool produced no value" })),
509
+ onSome: Result.succeed,
510
+ }),
511
+ ),
512
+ ],
513
+ ],
514
+ }),
515
+ ),
516
+ )
517
+
518
+ // Marks a value `outputSchema` rejected, apart from the tool's own errors
519
+ // (matched by class, so a tool error with the same tag can't pose as one).
520
+ class EncodeFailed extends Data.TaggedError("EncodeFailed")<{ readonly message: string }> {}
521
+
522
+ const check = (tool: AnyDescribed, value: unknown): Effect.Effect<unknown, EncodeFailed> =>
523
+ tool.outputSchema === undefined
524
+ ? Effect.void
525
+ : Schema.encodeUnknownEffect(tool.outputSchema)(value).pipe(
526
+ Effect.mapError((e) => new EncodeFailed({ message: e.message.slice(0, 500) })),
501
527
  )
502
- }
503
- }
504
- return Effect.failCause(cause)
505
- }
528
+
529
+ const isDeclaredBy = (tool: AnyLocalTool): ((e: unknown) => boolean) =>
530
+ tool.failureSchema === undefined ? () => false : Schema.is(tool.failureSchema)
531
+
532
+ // The error of a cause holding exactly one failure; `None` for anything mixed.
533
+ const soleError = (cause: Cause.Cause<unknown>): Option.Option<unknown> =>
534
+ Option.fromNullishOr(cause.reasons.length === 1 ? cause.reasons[0] : undefined).pipe(
535
+ Option.filter(Cause.isFailReason),
536
+ Option.map((reason) => reason.error),
537
+ )
538
+
539
+ // One failure the tool declared (or a rejected value) becomes a `Failure`.
540
+ // Anything else (undeclared errors, defects, interruption) propagates.
541
+ const declared = (
542
+ tool: AnyLocalTool,
543
+ cause: Cause.Cause<unknown>,
544
+ ): Effect.Effect<unknown, unknown> =>
545
+ soleError(cause).pipe(
546
+ Option.flatMap((error) =>
547
+ Match.value(error).pipe(
548
+ Match.when(Match.instanceOf(EncodeFailed), (e) =>
549
+ Option.some<unknown>(new OutputInvalid({ message: e.message })),
550
+ ),
551
+ Match.when(isDeclaredBy(tool), Option.some),
552
+ Match.orElse(() => Option.none()),
553
+ ),
554
+ ),
555
+ Option.match({ onNone: () => Effect.failCause(cause), onSome: Effect.succeed }),
556
+ )
506
557
 
507
558
  // ---------------------------------------------------------------------------
508
- // `continueWithResults` - bridge from a `Stream<ToolEvent>` to the loop's
509
- // emit shape. Drains the stream to the consumer in real-time, taps every
510
- // `Output` into an internal Ref, and at end-of-stream emits
511
- // `Loop.next(build(results))`. Recipe never sees the Ref.
512
- //
513
- // Dual: data-first `continueWithResults(stream, build)` and data-last
514
- // `stream.pipe(continueWithResults(build))` both work.
559
+ // To history and back
515
560
  // ---------------------------------------------------------------------------
516
561
 
562
+ const outputs = (tool: AnyDescribed | undefined): Schema.Codec<unknown, Schema.Json> =>
563
+ tool?.outputSchema ?? Content.Value
564
+
565
+ const failures = (tool: AnyDescribed | undefined): Schema.Codec<unknown, Schema.Json> =>
566
+ tool?.failureSchema === undefined ? CoreError : Schema.Union([tool.failureSchema, CoreError])
567
+
568
+ /** Any call's outcome, as `run` emits it. */
569
+ export type Outcome = ToolEvent.Output<string, unknown, unknown>
570
+
571
+ /** Every error the toolkit's tools declare (core's errors are always possible too). */
572
+ export type ToolkitFailure<T extends Toolkit> = ToolMap extends T
573
+ ? unknown
574
+ : ToolFailure<T[keyof T]>
575
+
576
+ /** `Toolkit.close` got an error the call's tool doesn't declare: a bug in the app. */
577
+ export class UndeclaredFailure extends Data.TaggedError("UndeclaredFailure")<{
578
+ readonly tool: string
579
+ readonly message: string
580
+ }> {}
581
+
582
+ const describeError = (error: unknown): string =>
583
+ Predicate.hasProperty(error, "_tag") && Predicate.isString(error._tag)
584
+ ? error._tag
585
+ : String(error)
586
+
517
587
  /**
518
- * Append a completed turn plus tool results to a state's history, converting
519
- * the results to wire-format `ToolCallOutput`s. Curried so it slots directly
520
- * into `continueWithResults`:
521
- *
522
- * Toolkit.run(toolkit, calls).pipe(
523
- * Toolkit.continueWithResults(Toolkit.appendToolResults(state, turn)),
524
- * )
588
+ * A call the app closes itself (denied, superseded), as the outcome `run`
589
+ * would emit. `error` must be core's or declared by the call's tool
590
+ * (`failureSchema`, `Tool.withFailures`), else this fails with `UndeclaredFailure`.
591
+ * Pass the tool's name to check `error` against that tool at compile time:
525
592
  *
526
- * Equivalent to `appendToHistory(state, turn, results.map(toToolCallOutput))`
527
- * — the helper just hides the wire-conversion step.
593
+ * Toolkit.close(toolkit, "issue_refund", call, new Escalated({ team: "finance" }))
528
594
  */
529
- export const appendToolResults =
530
- <S extends { readonly history: ReadonlyArray<HistoryItem> }>(state: S, turn: Turn) =>
531
- (results: ReadonlyArray<ToolResult>): S =>
532
- appendToHistory(state, turn, results.map(toToolCallOutput))
595
+ export function close<T extends Toolkit, N extends keyof T & string>(
596
+ toolkit: T,
597
+ name: N,
598
+ call: ToolCall,
599
+ error: ToolFailure<T[N]> | CoreError,
600
+ ): Effect.Effect<ToolEvent.Output<N, never, ToolFailure<T[N]> | CoreError>, UndeclaredFailure>
601
+ export function close<T extends Toolkit>(
602
+ toolkit: T,
603
+ call: ToolCall,
604
+ error: ToolkitFailure<T> | CoreError,
605
+ ): Effect.Effect<ToolEvent.Output<string, never, ToolkitFailure<T> | CoreError>, UndeclaredFailure>
606
+ export function close(
607
+ toolkit: Toolkit,
608
+ ...args: [name: string, call: ToolCall, error: unknown] | [call: ToolCall, error: unknown]
609
+ ): Effect.Effect<ToolEvent.Output<string, never, unknown>, UndeclaredFailure> {
610
+ const [name, call, error] = args.length === 3 ? args : [args[0].name, ...args]
611
+ return Match.value({ name, call, error }).pipe(
612
+ Match.when(
613
+ ({ call }) => call.name !== name,
614
+ () =>
615
+ Effect.fail(
616
+ new UndeclaredFailure({
617
+ tool: name,
618
+ message: `the call is for "${call.name}", not "${name}"`,
619
+ }),
620
+ ),
621
+ ),
622
+ Match.when(
623
+ ({ error }) => !Schema.is(failures(local(toolkit, name)))(error),
624
+ () =>
625
+ Effect.fail(
626
+ new UndeclaredFailure({
627
+ tool: name,
628
+ message: `"${name}" does not declare ${describeError(error)} in its failureSchema`,
629
+ }),
630
+ ),
631
+ ),
632
+ Match.orElse(() =>
633
+ Effect.succeed<ToolEvent.Output<string, never, unknown>>({
634
+ _tag: "Output",
635
+ call_id: call.call_id,
636
+ tool: name,
637
+ result: Result.fail(error),
638
+ }),
639
+ ),
640
+ )
641
+ }
533
642
 
534
643
  /**
535
- * Drain a `Stream<ToolEvent>` and return the accumulated `ToolResult`s
536
- * from every `Output` event. One-shot — the type is `Effect<results>`,
537
- * not a one-element stream — so it composes naturally with `Effect.map`
538
- * to build state.
644
+ * A call the app answers itself (`ask_user`, a signal's acknowledgement), as
645
+ * the outcome `run` would emit: `close`'s success side. `value` is typed by the
646
+ * tool's `outputSchema`; a value it rejects ends the call with `OutputInvalid`,
647
+ * as from `run`. Fails with `UndeclaredFailure` if `call` is for another tool.
648
+ */
649
+ export const complete = <T extends Toolkit, N extends keyof T & string>(
650
+ toolkit: T,
651
+ name: N,
652
+ call: ToolCall,
653
+ value: OutputOf<T[N]>,
654
+ ): Effect.Effect<ToolEvent.Output<N, OutputOf<T[N]>, OutputInvalid>, UndeclaredFailure> =>
655
+ Option.match(Option.fromNullishOr(call.name === name ? local(toolkit, name) : undefined), {
656
+ onNone: () =>
657
+ Effect.fail(
658
+ new UndeclaredFailure({
659
+ tool: name,
660
+ message:
661
+ call.name === name
662
+ ? `"${name}" is not a tool of this toolkit`
663
+ : `the call is for "${call.name}", not "${name}"`,
664
+ }),
665
+ ),
666
+ onSome: (tool) =>
667
+ check(tool, value).pipe(
668
+ Effect.match({
669
+ onFailure: (e) => Result.fail(new OutputInvalid({ message: e.message })),
670
+ onSuccess: () => Result.succeed(value),
671
+ }),
672
+ Effect.map((result) => ({ _tag: "Output", call_id: call.call_id, tool: name, result })),
673
+ ),
674
+ })
675
+
676
+ // ---------------------------------------------------------------------------
677
+ // Calls to declared tools
678
+ // ---------------------------------------------------------------------------
679
+
680
+ type InputOf<T> = T extends { readonly inputSchema: StandardSchemaV1<any, infer I> } ? I : never
681
+
682
+ // Distributes over the tool type, so a loose toolkit yields `{ tool: string, input: any }`.
683
+ type DeclaredCallOf<K extends string, X> = X extends { readonly _tag: "DeclaredTool" }
684
+ ? { readonly tool: K; readonly call: ToolCall; readonly input: InputOf<X> }
685
+ : never
686
+
687
+ /** A call to one of the toolkit's declared tools, its input decoded. `tool` is the discriminant. */
688
+ export type DeclaredCall<T extends Toolkit> = {
689
+ [K in keyof T & string]: DeclaredCallOf<K, T[K]>
690
+ }[keyof T & string]
691
+
692
+ /** A turn's calls, sorted for the loop. */
693
+ export type Split<T extends Toolkit> = {
694
+ /** For `Toolkit.run`: calls to tools with `run`, and to unknown names (closed there). */
695
+ readonly run: ReadonlyArray<ToolCall>
696
+ /** Calls to declared tools, decoded: handle them with `Match.discriminatorsExhaustive("tool")`. */
697
+ readonly declared: ReadonlyArray<DeclaredCall<T>>
698
+ /** Calls to declared tools with bad arguments, closed with `InputInvalid` for the model. */
699
+ readonly invalid: ReadonlyArray<ToolEvent.Output<string, never, InputInvalid>>
700
+ }
701
+
702
+ type Sorted =
703
+ | { readonly _tag: "run"; readonly call: ToolCall }
704
+ | { readonly _tag: "declared"; readonly declared: DeclaredCall<ToolMap> }
705
+ | { readonly _tag: "invalid"; readonly output: ToolEvent.Output<string, never, InputInvalid> }
706
+
707
+ const sortCall = (toolkit: Toolkit, call: ToolCall): Effect.Effect<Sorted> => {
708
+ const tool = lookup(toolkit, call.name)
709
+ return tool?._tag === "DeclaredTool"
710
+ ? Effect.map(
711
+ decodeInput(tool, call),
712
+ Result.match({
713
+ onFailure: (error): Sorted => ({
714
+ _tag: "invalid",
715
+ output: {
716
+ _tag: "Output",
717
+ call_id: call.call_id,
718
+ tool: call.name,
719
+ result: Result.fail(error),
720
+ },
721
+ }),
722
+ onSuccess: (input): Sorted => ({
723
+ _tag: "declared",
724
+ declared: { tool: call.name, call, input },
725
+ }),
726
+ }),
727
+ )
728
+ : Effect.succeed({ _tag: "run", call })
729
+ }
730
+
731
+ /**
732
+ * The turn's tool calls, sorted: `run` for `Toolkit.run`, and the calls to
733
+ * declared tools decoded and typed per tool. Handling `declared` with
734
+ * `Match.discriminatorsExhaustive("tool")` makes a new declared tool a compile
735
+ * error until the loop handles it:
539
736
  *
540
- * Counterpart to `continueWithResults`: `continueWithResults` bundles
541
- * drain + emit Next into one bridge; this one is the right arm of a
542
- * broadcast/fork-and-merge for callers that want to vary the left arm
543
- * (e.g., filter/tap ToolEvents before forwarding).
737
+ * const { run, declared, invalid } = yield* Toolkit.split(toolkit, turn)
738
+ * declared.map(Match.discriminatorsExhaustive("tool")({ ask_user: ..., escalate: ... }))
544
739
  */
545
- export const collectResults = <E, R>(
546
- stream: Stream.Stream<ToolEvent, E, R>,
547
- ): Effect.Effect<ReadonlyArray<ToolResult>, E, R> =>
548
- stream.pipe(
549
- Stream.filter(isOutput),
550
- Stream.map((e) => e.result),
551
- Stream.runCollect,
740
+ export const split = <T extends Toolkit>(toolkit: T, turn: Turn): Effect.Effect<Split<T>> =>
741
+ Effect.map(
742
+ Effect.forEach(getToolCalls(turn), (call) => sortCall(toolkit, call)),
743
+ (sorted) => ({
744
+ run: sorted.flatMap((s) => (s._tag === "run" ? [s.call] : [])),
745
+ declared: sorted.flatMap((s) =>
746
+ s._tag === "declared" ? [s.declared as DeclaredCall<T>] : [],
747
+ ),
748
+ invalid: sorted.flatMap((s) => (s._tag === "invalid" ? [s.output] : [])),
749
+ }),
552
750
  )
553
751
 
752
+ /** Narrows to the `Output` members (also in a loop's mixed stream), keeping the per-tool types. */
753
+ export const isOutcome = <A extends { readonly _tag: string }>(
754
+ e: A,
755
+ ): e is Extract<A, { readonly _tag: "Output" }> => e._tag === "Output"
756
+
757
+ const toOutput = (
758
+ toolkit: Toolkit,
759
+ event: Outcome,
760
+ ): Effect.Effect<ToolCallOutput, Schema.SchemaError> => {
761
+ const tool = local(toolkit, event.tool)
762
+ return Result.match(event.result, {
763
+ onSuccess: (value) => ToolOutput.success(event, outputs(tool), value),
764
+ onFailure: (error) => ToolOutput.failure(event, failures(tool), error),
765
+ })
766
+ }
767
+
554
768
  /**
555
- * Bridge from a `Stream<ToolEvent>` to the loop's emit shape. Forwards
556
- * every ToolEvent downstream as a `Loop.value` and at end-of-stream emits
557
- * one `Loop.next(build(results))` carrying the accumulated `ToolResult`s
558
- * from terminal `Output` events.
559
- *
560
- * Conceptually a broadcast fork (one arm passes events through, the other
561
- * drains them into a state). Implemented as a single-pass Ref tap + concat
562
- * for zero buffering — the broadcast version is observationally equivalent
563
- * but holds events in a PubSub until both arms drain. The public primitives
564
- * (`Loop.value`, `collectResults`, `Loop.next`) compose the same pattern
565
- * when you need to vary an arm.
566
- *
567
- * Dual: data-first `continueWithResults(stream, build)` and data-last
568
- * `stream.pipe(continueWithResults(build))` both work.
769
+ * An outcome as history, encoded with the tool's schemas (never stringified:
770
+ * that is the provider codec's job). `output(toolkit)` is the pipeable form.
569
771
  */
570
- export const continueWithResults: {
571
- <S>(
572
- build: (results: ReadonlyArray<ToolResult>) => S,
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>,
576
- build: (results: ReadonlyArray<ToolResult>) => S,
577
- ): Stream.Stream<Loop.Step<ToolEvent, S>, E, R>
772
+ export function output(
773
+ toolkit: Toolkit,
774
+ ): (event: Outcome) => Effect.Effect<ToolCallOutput, Schema.SchemaError>
775
+ export function output(
776
+ toolkit: Toolkit,
777
+ event: Outcome,
778
+ ): Effect.Effect<ToolCallOutput, Schema.SchemaError>
779
+ export function output(toolkit: Toolkit, event?: Outcome) {
780
+ return event === undefined ? (e: Outcome) => toOutput(toolkit, e) : toOutput(toolkit, event)
781
+ }
782
+
783
+ /** History back to a typed `Result` for tool `name`, decoded with its schemas. */
784
+ export function decode<T extends Toolkit, N extends keyof T & string>(
785
+ toolkit: T,
786
+ name: N,
787
+ item: ToolCallOutput,
788
+ ): Effect.Effect<
789
+ Result.Result<OutputOf<T[N]>, ToolFailure<T[N]> | ToolOutput.CoreError>,
790
+ Schema.SchemaError
791
+ >
792
+ export function decode(
793
+ toolkit: Toolkit,
794
+ name: string,
795
+ item: ToolCallOutput,
796
+ ): Effect.Effect<Result.Result<unknown, unknown>, Schema.SchemaError> {
797
+ const tool = local(toolkit, name)
798
+ const decodeWith = (schema: Schema.Codec<unknown, unknown>) =>
799
+ Effect.flatMap(ToolOutput.encoded(item.output), Schema.decodeUnknownEffect(schema))
800
+ return Match.value(item).pipe(
801
+ Match.when({ isError: true }, () => Effect.map(decodeWith(failures(tool)), Result.fail)),
802
+ Match.orElse(() =>
803
+ Option.match(Option.fromNullishOr(tool?.outputSchema), {
804
+ // Without `outputSchema` the parts' own types say what the value is.
805
+ onNone: () => Effect.succeed(Result.succeed(ToolOutput.value(item.output))),
806
+ onSome: (schema) => Effect.map(decodeWith(schema), Result.succeed),
807
+ }),
808
+ ),
809
+ )
810
+ }
811
+
812
+ // ---------------------------------------------------------------------------
813
+ // Into the loop
814
+ // ---------------------------------------------------------------------------
815
+
816
+ type Tagged = { readonly _tag: string }
817
+
818
+ // An app's own events may travel alongside; `Output` is reserved for outcomes.
819
+ const isOutcomeOf = <A extends Tagged>(e: A): e is A & Outcome => e._tag === "Output"
820
+
821
+ /**
822
+ * Forward every event as a `Loop.value`, then continue the loop with
823
+ * `build(outcomes)`. `build` is an Effect, so it can encode. The stream may
824
+ * carry the app's own tagged events too (any tag but `Output`).
825
+ */
826
+ export const continueWith: {
827
+ <S, E2, R2>(
828
+ build: (outcomes: ReadonlyArray<Outcome>) => Effect.Effect<S, E2, R2>,
829
+ ): <A extends Tagged, E, R>(
830
+ stream: Stream.Stream<A, E, R>,
831
+ ) => Stream.Stream<Loop.Step<A, S>, E | E2, R | R2>
832
+ <A extends Tagged, E, R, S, E2, R2>(
833
+ stream: Stream.Stream<A, E, R>,
834
+ build: (outcomes: ReadonlyArray<Outcome>) => Effect.Effect<S, E2, R2>,
835
+ ): Stream.Stream<Loop.Step<A, S>, E | E2, R | R2>
578
836
  } = Function.dual(
579
837
  2,
580
- <S, E, R>(
581
- stream: Stream.Stream<ToolEvent, E, R>,
582
- build: (results: ReadonlyArray<ToolResult>) => S,
583
- ): Stream.Stream<Loop.Step<ToolEvent, S>, E, R> =>
838
+ <A extends Tagged, E, R, S, E2, R2>(
839
+ stream: Stream.Stream<A, E, R>,
840
+ build: (outcomes: ReadonlyArray<Outcome>) => Effect.Effect<S, E2, R2>,
841
+ ): Stream.Stream<Loop.Step<A, S>, E | E2, R | R2> =>
584
842
  Stream.unwrap(
585
- Effect.gen(function* () {
586
- const ref = yield* Ref.make<ReadonlyArray<ToolResult>>([])
587
- const tapped = stream.pipe(
588
- Stream.tap((e) =>
589
- isOutput(e) ? Ref.update(ref, (acc) => Arr.append(acc, e.result)) : Effect.void,
843
+ Effect.map(Ref.make<ReadonlyArray<Outcome>>([]), (outcomes) =>
844
+ Stream.concat(
845
+ stream.pipe(
846
+ Stream.tap((e) =>
847
+ isOutcomeOf(e) ? Ref.update(outcomes, Arr.append<Outcome>(e)) : Effect.void,
848
+ ),
849
+ Stream.map(Loop.value),
590
850
  ),
591
- Stream.map(Loop.value),
592
- )
593
- const continuation = Stream.unwrap(
594
- Ref.get(ref).pipe(Effect.map((acc) => Loop.next(build(acc)))),
595
- )
596
- return tapped.pipe(Stream.concat(continuation))
597
- }),
851
+ Stream.unwrap(Ref.get(outcomes).pipe(Effect.flatMap(build), Effect.map(Loop.next))),
852
+ ),
853
+ ),
598
854
  ),
599
855
  )
856
+
857
+ /** Encode each outcome with `output` and append the turn plus the outputs to history. */
858
+ export const appendResults =
859
+ <S extends { readonly history: ReadonlyArray<HistoryItem> }>(
860
+ toolkit: Toolkit,
861
+ state: S,
862
+ turn: Turn,
863
+ ) =>
864
+ (outcomes: ReadonlyArray<Outcome>): Effect.Effect<S, Schema.SchemaError> =>
865
+ Effect.map(Effect.forEach(outcomes, output(toolkit)), (items) =>
866
+ appendToHistory(state, turn, items),
867
+ )
868
+
869
+ /** `continueWith(appendResults(toolkit, state, turn))`. */
870
+ export const next = <S extends { readonly history: ReadonlyArray<HistoryItem> }>(
871
+ toolkit: Toolkit,
872
+ state: S,
873
+ turn: Turn,
874
+ ) => continueWith(appendResults(toolkit, state, turn))