@effect-uai/core 0.9.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 (112) hide show
  1. package/dist/{AiError-CrdR1EQi.d.mts → AiError-Bf1pq4BB.d.mts} +8 -3
  2. package/dist/{AiError-CrdR1EQi.d.mts.map → AiError-Bf1pq4BB.d.mts.map} +1 -1
  3. package/dist/{Audio-DDSKCkSe.d.mts → Audio-CR7CSljJ.d.mts} +2 -2
  4. package/dist/{Audio-DDSKCkSe.d.mts.map → Audio-CR7CSljJ.d.mts.map} +1 -1
  5. package/dist/{Image-C_Zjpwhc.d.mts → Image-BZmKfIdq.d.mts} +2 -2
  6. package/dist/{Image-C_Zjpwhc.d.mts.map → Image-BZmKfIdq.d.mts.map} +1 -1
  7. package/dist/{Items-CgKD6Npw.d.mts → Items-BnIduEV2.d.mts} +9 -9
  8. package/dist/{Items-CgKD6Npw.d.mts.map → Items-BnIduEV2.d.mts.map} +1 -1
  9. package/dist/{Loop-B5_cqumi.d.mts → Loop-D8nWpowc.d.mts} +2 -2
  10. package/dist/{Loop-B5_cqumi.d.mts.map → Loop-D8nWpowc.d.mts.map} +1 -1
  11. package/dist/{Media-DOLyaygI.d.mts → Media-D_CpcM1Z.d.mts} +1 -1
  12. package/dist/{Media-DOLyaygI.d.mts.map → Media-D_CpcM1Z.d.mts.map} +1 -1
  13. package/dist/{StructuredFormat-CMJFD3sn.d.mts → StructuredFormat-DHaUJBdd.d.mts} +1 -1
  14. package/dist/{StructuredFormat-CMJFD3sn.d.mts.map → StructuredFormat-DHaUJBdd.d.mts.map} +1 -1
  15. package/dist/{Tool-0XUIT2A2.d.mts → Tool-2lC_XMth.d.mts} +64 -12
  16. package/dist/Tool-2lC_XMth.d.mts.map +1 -0
  17. package/dist/{ToolEvent-DYrAUJGs.d.mts → ToolEvent-cWZ2BnbS.d.mts} +2 -2
  18. package/dist/ToolEvent-cWZ2BnbS.d.mts.map +1 -0
  19. package/dist/{ToolResult-BZd-cEWW.d.mts → ToolResult-CL4hK_9z.d.mts} +10 -4
  20. package/dist/ToolResult-CL4hK_9z.d.mts.map +1 -0
  21. package/dist/{Toolkit-BKC8b8Xo.d.mts → Toolkit-Dr9l_hNy.d.mts} +64 -23
  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/domain/AiError.d.mts +2 -2
  36. package/dist/domain/AiError.mjs +22 -3
  37. package/dist/domain/AiError.mjs.map +1 -1
  38. package/dist/domain/Audio.d.mts +1 -1
  39. package/dist/domain/Image.d.mts +1 -1
  40. package/dist/domain/Items.d.mts +1 -1
  41. package/dist/domain/Media.d.mts +1 -1
  42. package/dist/domain/Music.d.mts +1 -1
  43. package/dist/domain/Turn.d.mts +3 -3
  44. package/dist/embedding-model/Embedding.d.mts +1 -1
  45. package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
  46. package/dist/index.d.mts +17 -12
  47. package/dist/index.mjs +14 -9
  48. package/dist/language-model/LanguageModel.d.mts +4 -4
  49. package/dist/loop/Loop.d.mts +1 -1
  50. package/dist/music-generator/MusicGenerator.d.mts +2 -2
  51. package/dist/observability/Metrics.d.mts +1 -1
  52. package/dist/retry/Retry.d.mts +1 -1
  53. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +2 -2
  54. package/dist/structured-format/StructuredFormat.d.mts +1 -1
  55. package/dist/testing/MockMusicGenerator.d.mts +1 -1
  56. package/dist/testing/MockProvider.d.mts +1 -1
  57. package/dist/testing/MockSpeechSynthesizer.d.mts +1 -1
  58. package/dist/tool/Approval.d.mts +19 -7
  59. package/dist/tool/Approval.d.mts.map +1 -1
  60. package/dist/tool/Approval.mjs +18 -11
  61. package/dist/tool/Approval.mjs.map +1 -1
  62. package/dist/tool/Approval.test.mjs +46 -7
  63. package/dist/tool/Approval.test.mjs.map +1 -1
  64. package/dist/tool/HistoryCheck.d.mts +2 -2
  65. package/dist/tool/Tool.d.mts +2 -2
  66. package/dist/tool/Tool.mjs +86 -22
  67. package/dist/tool/Tool.mjs.map +1 -1
  68. package/dist/tool/ToolEvent.d.mts +1 -1
  69. package/dist/tool/ToolEvent.mjs +5 -0
  70. package/dist/tool/ToolEvent.mjs.map +1 -1
  71. package/dist/tool/ToolResult.d.mts +2 -2
  72. package/dist/tool/ToolResult.mjs +15 -6
  73. package/dist/tool/ToolResult.mjs.map +1 -1
  74. package/dist/tool/Toolkit.d.mts +2 -2
  75. package/dist/tool/Toolkit.mjs +62 -22
  76. package/dist/tool/Toolkit.mjs.map +1 -1
  77. package/dist/tool/Toolkit.test.mjs +163 -9
  78. package/dist/tool/Toolkit.test.mjs.map +1 -1
  79. package/dist/transcriber/Transcriber.d.mts +2 -2
  80. package/dist/web-read/WebRead.d.mts +44 -0
  81. package/dist/web-read/WebRead.d.mts.map +1 -0
  82. package/dist/web-read/WebRead.mjs +18 -0
  83. package/dist/web-read/WebRead.mjs.map +1 -0
  84. package/dist/web-read/WebReadTool.d.mts +57 -0
  85. package/dist/web-read/WebReadTool.d.mts.map +1 -0
  86. package/dist/web-read/WebReadTool.mjs +59 -0
  87. package/dist/web-read/WebReadTool.mjs.map +1 -0
  88. package/dist/web-search/WebSearch.d.mts +1 -1
  89. package/dist/web-search/WebSearchTool.d.mts +12 -6
  90. package/dist/web-search/WebSearchTool.d.mts.map +1 -1
  91. package/dist/web-search/WebSearchTool.mjs +10 -5
  92. package/dist/web-search/WebSearchTool.mjs.map +1 -1
  93. package/package.json +21 -1
  94. package/src/browser/Browser.ts +268 -0
  95. package/src/browser/BrowserError.ts +145 -0
  96. package/src/browser/BrowserTool.ts +161 -0
  97. package/src/domain/AiError.ts +27 -1
  98. package/src/index.ts +5 -0
  99. package/src/tool/Approval.test.ts +55 -5
  100. package/src/tool/Approval.ts +72 -27
  101. package/src/tool/Tool.ts +146 -47
  102. package/src/tool/ToolEvent.ts +5 -0
  103. package/src/tool/ToolResult.ts +14 -8
  104. package/src/tool/Toolkit.test.ts +171 -2
  105. package/src/tool/Toolkit.ts +173 -60
  106. package/src/web-read/WebRead.ts +70 -0
  107. package/src/web-read/WebReadTool.ts +96 -0
  108. package/src/web-search/WebSearchTool.ts +11 -5
  109. package/dist/Tool-0XUIT2A2.d.mts.map +0 -1
  110. package/dist/ToolEvent-DYrAUJGs.d.mts.map +0 -1
  111. package/dist/ToolResult-BZd-cEWW.d.mts.map +0 -1
  112. package/dist/Toolkit-BKC8b8Xo.d.mts.map +0 -1
package/src/tool/Tool.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
2
- import { Effect, Schema } from "effect"
2
+ import { Effect, Match, Schema } from "effect"
3
3
  import type { ToolCall, ToolCallOutput } from "../domain/Items.js"
4
4
  import { toolCallOutput } from "../domain/Items.js"
5
+ import { serializeValue } from "./ToolResult.js"
5
6
 
6
7
  export class ToolError extends Schema.TaggedErrorClass<ToolError>("@betalyra/effect-uai/ToolError")(
7
8
  "ToolError",
@@ -37,6 +38,35 @@ export class ToolValidationError extends Schema.TaggedErrorClass<ToolValidationE
37
38
  issues: Schema.Array(ToolValidationIssue),
38
39
  }) {}
39
40
 
41
+ /**
42
+ * The deliberate "speak to the model" failure sentinel. A tool's `run` that
43
+ * fails with a `ToolFailed` (or a bare `string`, its sugar) is absorbed by
44
+ * `Toolkit.run` into a `ToolResult.Failure` the model reads and adapts to,
45
+ * instead of ending the run. Every other error type propagates typed on the
46
+ * executor's stream. `kind` overrides the synthesized `Failure.kind`
47
+ * (default `"tool_failed"`) so a tool can hand the model a distinguishable
48
+ * category (`"not_found"`, `"rate_limited"`) without a new result variant.
49
+ */
50
+ export class ToolFailed extends Schema.TaggedErrorClass<ToolFailed>(
51
+ "@betalyra/effect-uai/ToolFailed",
52
+ )("ToolFailed", {
53
+ message: Schema.String,
54
+ kind: Schema.optional(Schema.String),
55
+ }) {}
56
+
57
+ /**
58
+ * Fail a tool's `run` with a model-visible message (optionally a `kind`).
59
+ * `yield* Tool.fail("page not found", { kind: "not_found" })` inside a
60
+ * `run`'s `Effect.gen`; the executor absorbs it as `ToolResult.Failure`.
61
+ */
62
+ export const fail = (
63
+ message: string,
64
+ options?: { readonly kind?: string },
65
+ ): Effect.Effect<never, ToolFailed> =>
66
+ Effect.fail(
67
+ new ToolFailed({ message, ...(options?.kind !== undefined ? { kind: options.kind } : {}) }),
68
+ )
69
+
40
70
  /**
41
71
  * Schemas accepted on `Tool.inputSchema`. Must implement both Standard
42
72
  * Schema validation and JSON Schema conversion (for rendering tool
@@ -79,6 +109,25 @@ export const fromStandardSchema = <S extends StandardSchemaV1 & StandardJSONSche
79
109
  ): S & ToolInputSchema<StandardSchemaV1.InferOutput<S>> =>
80
110
  schema as S & ToolInputSchema<StandardSchemaV1.InferOutput<S>>
81
111
 
112
+ /**
113
+ * Input schema for a tool that takes no arguments. Renders a clean
114
+ * `{ type: "object", properties: {} }`; validation accepts anything and
115
+ * yields `{}`. Use it instead of `fromEffectSchema(Schema.Struct({}))`,
116
+ * which renders a degenerate `anyOf` (object-or-array) that strict
117
+ * providers like Gemini reject.
118
+ */
119
+ export const noInput: ToolInputSchema<Record<string, never>> = {
120
+ "~standard": {
121
+ version: 1,
122
+ vendor: "effect-uai",
123
+ validate: () => ({ value: {} }),
124
+ jsonSchema: {
125
+ input: () => ({ type: "object", properties: {} }),
126
+ output: () => ({ type: "object", properties: {} }),
127
+ },
128
+ },
129
+ }
130
+
82
131
  /**
83
132
  * Emit one intermediate `Event` from inside a tool's `run`. Backed by the
84
133
  * executor's per-call queue, so it returns an `Effect` (backpressure) and
@@ -98,12 +147,12 @@ export type Emit<Event> = (event: Event) => Effect.Effect<void>
98
147
  * no local executor — see their constructors below. `Tool` keeps its name (and
99
148
  * the `Tool.make` constructor) because the local kind is by far the common one.
100
149
  */
101
- export type Tool<Name extends string, Input, Event, Output, R = never> = {
150
+ export type Tool<Name extends string, Input, Event, Output, E = unknown, R = never> = {
102
151
  readonly _tag: "LocalTool"
103
152
  readonly name: Name
104
153
  readonly description: string
105
154
  readonly inputSchema: ToolInputSchema<Input>
106
- readonly run: (input: Input, emit: Emit<Event>) => Effect.Effect<Output, unknown, R>
155
+ readonly run: (input: Input, emit: Emit<Event>) => Effect.Effect<Output, E, R>
107
156
  /**
108
157
  * Bound on this tool's emit queue. Unbounded when omitted. Per-tool because
109
158
  * backpressure depends on how a given tool emits, unlike `concurrency`
@@ -162,11 +211,11 @@ export type InteractionTool<Name extends string, Input> = {
162
211
  }
163
212
 
164
213
  /** A local (executable) tool of any shape. Readability alias. */
165
- export type AnyLocalTool<R = any> = Tool<string, any, any, any, R>
214
+ export type AnyLocalTool<E = any, R = any> = Tool<string, any, any, any, E, R>
166
215
 
167
216
  /** Any of the four model-visible tool kinds. */
168
- export type AnyTool<R = any> =
169
- | Tool<string, any, any, any, R>
217
+ export type AnyTool<E = any, R = any> =
218
+ | Tool<string, any, any, any, E, R>
170
219
  | ProviderTool<string, any, string, any>
171
220
  | SignalTool<string, any>
172
221
  | InteractionTool<string, any>
@@ -178,7 +227,10 @@ export type DecodableTool<Input> = {
178
227
  }
179
228
 
180
229
  /** Extract the `R` of a local tool; non-local kinds contribute `never`. */
181
- export type ToolR<T> = T extends Tool<string, any, any, any, infer R> ? R : never
230
+ export type ToolR<T> = T extends Tool<string, any, any, any, any, infer R> ? R : never
231
+
232
+ /** Extract the `E` of a local tool; non-local kinds contribute `never`. */
233
+ export type ToolE<T> = T extends Tool<string, any, any, any, infer E, any> ? E : never
182
234
 
183
235
  /**
184
236
  * Provider-agnostic tool descriptor. Each provider maps `inputSchema`
@@ -193,9 +245,9 @@ export type ToolDescriptor = {
193
245
  }
194
246
 
195
247
  /** A model-visible tool with a local `Effect` executor. */
196
- export const make = <Name extends string, Input, Event, Output, R = never>(
197
- spec: Omit<Tool<Name, Input, Event, Output, R>, "_tag">,
198
- ): Tool<Name, Input, Event, Output, R> => ({ _tag: "LocalTool", ...spec })
248
+ export const make = <Name extends string, Input, Event, Output, E = never, R = never>(
249
+ spec: Omit<Tool<Name, Input, Event, Output, E, R>, "_tag">,
250
+ ): Tool<Name, Input, Event, Output, E, R> => ({ _tag: "LocalTool", ...spec })
199
251
 
200
252
  /**
201
253
  * A provider-hosted tool (native web search, code execution, RAG grounding).
@@ -245,10 +297,20 @@ export const withName = <N extends string, T extends AnyTool>(
245
297
  * Descriptors stay identical to production, so a mocked toolkit can't drift
246
298
  * from the contract the model sees.
247
299
  */
248
- export const withRun = <Name extends string, Input, Event, Output, R, Output2, R2 = never>(
249
- tool: Tool<Name, Input, Event, Output, R>,
250
- run: (input: Input, emit: Emit<Event>) => Effect.Effect<Output2, unknown, R2>,
251
- ): Tool<Name, Input, Event, Output2, R2> => ({ ...tool, run })
300
+ export const withRun = <
301
+ Name extends string,
302
+ Input,
303
+ Event,
304
+ Output,
305
+ E,
306
+ R,
307
+ Output2,
308
+ E2 = never,
309
+ R2 = never,
310
+ >(
311
+ tool: Tool<Name, Input, Event, Output, E, R>,
312
+ run: (input: Input, emit: Emit<Event>) => Effect.Effect<Output2, E2, R2>,
313
+ ): Tool<Name, Input, Event, Output2, E2, R2> => ({ ...tool, run })
252
314
 
253
315
  /** Render tools to provider-agnostic descriptors. Every kind is model-visible. */
254
316
  export const toDescriptors = (tools: ReadonlyArray<AnyTool>): ReadonlyArray<ToolDescriptor> =>
@@ -275,8 +337,51 @@ export const descriptorsOf = (
275
337
  ): ReadonlyArray<ToolDescriptor> =>
276
338
  toolkit === undefined ? [] : toDescriptors(Object.values(toolkit))
277
339
 
278
- const toToolError = (call: ToolCall, toolName: string, message: string) => (cause: unknown) =>
279
- new ToolError({ call_id: call.call_id, tool: toolName, message, cause })
340
+ /**
341
+ * Outcome of decoding a call's `arguments` against a tool's `inputSchema`,
342
+ * as data so both the typed (`decodeArgs`) and result-synthesizing
343
+ * (`Toolkit.run`) callers can translate it their own way.
344
+ */
345
+ export type DecodeResult<Input> =
346
+ | { readonly _tag: "ok"; readonly input: Input }
347
+ | { readonly _tag: "parseError" }
348
+ | { readonly _tag: "invalid"; readonly issues: ReadonlyArray<StandardSchemaV1.Issue> }
349
+
350
+ const VALIDATION_THREW: ReadonlyArray<StandardSchemaV1.Issue> = [
351
+ { message: "input schema validation threw" },
352
+ ]
353
+
354
+ /**
355
+ * Parse and validate a call's `arguments`. Empty arguments normalize to
356
+ * `{}` (some models emit `""` for zero-arg tools); a throwing validator is
357
+ * captured, not propagated. Never fails.
358
+ */
359
+ export const decodeCallInput = <Input>(
360
+ tool: DecodableTool<Input>,
361
+ call: ToolCall,
362
+ ): Effect.Effect<DecodeResult<Input>> => {
363
+ const raw = call.arguments.trim() === "" ? "{}" : call.arguments
364
+ return Effect.try({
365
+ // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
366
+ try: () => JSON.parse(raw) as unknown,
367
+ catch: (): DecodeResult<Input> => ({ _tag: "parseError" }),
368
+ }).pipe(
369
+ Effect.flatMap((parsed) =>
370
+ Effect.tryPromise({
371
+ try: () => Promise.resolve(tool.inputSchema["~standard"].validate(parsed)),
372
+ catch: (): DecodeResult<Input> => ({ _tag: "invalid", issues: VALIDATION_THREW }),
373
+ }).pipe(
374
+ Effect.map(
375
+ (result): DecodeResult<Input> =>
376
+ result.issues !== undefined
377
+ ? { _tag: "invalid", issues: result.issues }
378
+ : { _tag: "ok", input: result.value },
379
+ ),
380
+ ),
381
+ ),
382
+ Effect.catch((captured: DecodeResult<Input>) => Effect.succeed(captured)),
383
+ )
384
+ }
280
385
 
281
386
  /**
282
387
  * Decode and validate a function_call's JSON `arguments` against the tool's
@@ -291,28 +396,26 @@ export const decodeArgs = <Input>(
291
396
  tool: DecodableTool<Input>,
292
397
  call: ToolCall,
293
398
  ): Effect.Effect<Input, ToolError | ToolValidationError> =>
294
- Effect.gen(function* () {
295
- const parsed = yield* Effect.try({
296
- // inputSchema validates through Standard Schema, not Effect Schema, so its
297
- // JSON codecs don't apply; the parse is separate to keep ToolError (bad
298
- // JSON) distinct from ToolValidationError (bad shape).
299
- // @effect-diagnostics-next-line effect/preferSchemaOverJson:off
300
- try: () => JSON.parse(call.arguments) as unknown,
301
- catch: toToolError(call, tool.name, "Failed to parse JSON arguments"),
302
- })
303
-
304
- const result = yield* Effect.promise(() =>
305
- Promise.resolve(tool.inputSchema["~standard"].validate(parsed)),
306
- )
307
- if (result.issues !== undefined) {
308
- return yield* new ToolValidationError({
309
- call_id: call.call_id,
310
- tool: tool.name,
311
- issues: result.issues,
312
- })
313
- }
314
- return result.value
315
- })
399
+ decodeCallInput(tool, call).pipe(
400
+ Effect.flatMap(
401
+ Match.type<DecodeResult<Input>>().pipe(
402
+ Match.tag("ok", ({ input }) => Effect.succeed(input)),
403
+ Match.tag("parseError", () =>
404
+ Effect.fail(
405
+ new ToolError({
406
+ call_id: call.call_id,
407
+ tool: tool.name,
408
+ message: "Failed to parse JSON arguments",
409
+ }),
410
+ ),
411
+ ),
412
+ Match.tag("invalid", ({ issues }) =>
413
+ Effect.fail(new ToolValidationError({ call_id: call.call_id, tool: tool.name, issues })),
414
+ ),
415
+ Match.exhaustive,
416
+ ),
417
+ ),
418
+ )
316
419
 
317
420
  /**
318
421
  * Decode and validate the JSON arguments of a function_call against the
@@ -320,15 +423,11 @@ export const decodeArgs = <Input>(
320
423
  * function_call_output item. Single-shot: intermediate events emitted by
321
424
  * `run` are discarded (use `Toolkit.run` to stream them).
322
425
  */
323
- export const execute = <Name extends string, Input, Event, Output, R>(
324
- tool: Tool<Name, Input, Event, Output, R>,
426
+ export const execute = <Name extends string, Input, Event, Output, E, R>(
427
+ tool: Tool<Name, Input, Event, Output, E, R>,
325
428
  call: ToolCall,
326
- ): Effect.Effect<ToolCallOutput, ToolError | ToolValidationError, R> =>
429
+ ): Effect.Effect<ToolCallOutput, E | ToolError | ToolValidationError, R> =>
327
430
  decodeArgs(tool, call).pipe(
328
- Effect.flatMap((input) =>
329
- tool
330
- .run(input, () => Effect.void)
331
- .pipe(Effect.mapError(toToolError(call, tool.name, "Tool execution failed"))),
332
- ),
333
- Effect.map((output) => toolCallOutput(call.call_id, JSON.stringify(output))),
431
+ Effect.flatMap((input) => tool.run(input, () => Effect.void)),
432
+ Effect.map((output) => toolCallOutput(call.call_id, serializeValue(output))),
334
433
  )
@@ -7,6 +7,11 @@
7
7
  *
8
8
  * Recipes thread `ToolEvent.Output.result` through `continueWithResults` and
9
9
  * apply `toToolCallOutput` when appending to history.
10
+ *
11
+ * Demux note: `Progress` carries `call_id` / `tool` at the top level, while
12
+ * `Output` nests them inside `result` (the `ToolResult` already carries both).
13
+ * A consumer grouping events by call reads `e.call_id` for `Progress` and
14
+ * `e.result.call_id` for `Output`.
10
15
  */
11
16
  import { Data } from "effect"
12
17
  import type { ToolResult } from "./ToolResult.js"
@@ -95,14 +95,20 @@ export const validationError = (call: ToolCall, reason?: string): ToolResult =>
95
95
  // Wire conversion - the one place structured → string happens.
96
96
  // ---------------------------------------------------------------------------
97
97
 
98
+ /**
99
+ * Serialize a success value for the wire. A `string` passes through raw
100
+ * (models read plain text best; JSON.stringify would quote and escape it);
101
+ * `undefined` becomes `"null"`; anything else is JSON.
102
+ */
103
+ export const serializeValue = (value: unknown): string =>
104
+ typeof value === "string" ? value : JSON.stringify(value ?? null)
105
+
106
+ /** The one failure shape the model sees: `{"error":{"kind","message"}}`. */
107
+ const failureBody = (kind: string, message?: string): string =>
108
+ JSON.stringify({ error: message !== undefined ? { kind, message } : { kind } })
109
+
98
110
  export const toToolCallOutput = (r: ToolResult): ToolCallOutput =>
99
111
  ToolResult.$match(r, {
100
- Ok: (v) => toolCallOutput(v.call_id, JSON.stringify(v.value)),
101
- Failure: (f) =>
102
- toolCallOutput(
103
- f.call_id,
104
- JSON.stringify(
105
- f.reason !== undefined ? { kind: f.kind, reason: f.reason } : { kind: f.kind },
106
- ),
107
- ),
112
+ Ok: (v) => toolCallOutput(v.call_id, serializeValue(v.value)),
113
+ Failure: (f) => toolCallOutput(f.call_id, failureBody(f.kind, f.reason)),
108
114
  })
@@ -1,11 +1,29 @@
1
1
  import { Context, Effect, Layer, pipe, Schema, Stream } from "effect"
2
2
  import { describe, expect, expectTypeOf, it } from "vitest"
3
+ import * as AiError from "../domain/AiError.js"
3
4
  import type { ToolCall } from "../domain/Items.js"
4
- import { isOutput } from "./ToolEvent.js"
5
- import { isOk } from "./ToolResult.js"
5
+ import { type ToolEvent, isOutput, isProgress } from "./ToolEvent.js"
6
+ import { isFailure, isOk, toToolCallOutput } from "./ToolResult.js"
6
7
  import * as Tool from "./Tool.js"
7
8
  import * as Toolkit from "./Toolkit.js"
8
9
 
10
+ const Empty = Schema.Struct({})
11
+ const toolCall = (name: string, id: string, args = "{}"): ToolCall => ({
12
+ type: "function_call",
13
+ call_id: id,
14
+ name,
15
+ arguments: args,
16
+ })
17
+ const runToArray = <T extends Toolkit.Toolkit>(
18
+ toolkit: T,
19
+ calls: ReadonlyArray<ToolCall>,
20
+ options?: Toolkit.ExecuteOptions,
21
+ ) =>
22
+ Toolkit.run(toolkit, calls, options).pipe(
23
+ Stream.runCollect,
24
+ Effect.map((events): ReadonlyArray<ToolEvent> => Array.from(events)),
25
+ )
26
+
9
27
  describe("Tool.toDescriptors", () => {
10
28
  const GetWeatherInput = Schema.Struct({ city: Schema.String })
11
29
 
@@ -299,3 +317,154 @@ describe("Toolkit.wrap - middleware", () => {
299
317
  expect(s?._tag === "Failure" && s.kind).toBe("non_local_tool")
300
318
  })
301
319
  })
320
+
321
+ describe("Toolkit.run - executor outcomes", () => {
322
+ const plain = <N extends string>(name: N, out: unknown) =>
323
+ Tool.make({
324
+ name,
325
+ description: "",
326
+ inputSchema: Tool.fromEffectSchema(Empty),
327
+ run: () => Effect.succeed(out),
328
+ })
329
+
330
+ it("absorbs a bare string failure as a tool_failed result", async () => {
331
+ const t = Tool.make({
332
+ name: "t",
333
+ description: "",
334
+ inputSchema: Tool.fromEffectSchema(Empty),
335
+ run: () => Effect.fail("nope"),
336
+ })
337
+ const events = await Effect.runPromise(runToArray(Toolkit.make(t), [toolCall("t", "1")]))
338
+ const result = events.filter(isOutput)[0]?.result
339
+ expect(result).toMatchObject({ _tag: "Failure", kind: "tool_failed", reason: "nope" })
340
+ })
341
+
342
+ it("absorbs Tool.fail with a custom kind", async () => {
343
+ const t = Tool.make({
344
+ name: "t",
345
+ description: "",
346
+ inputSchema: Tool.fromEffectSchema(Empty),
347
+ run: () => Tool.fail("missing", { kind: "not_found" }),
348
+ })
349
+ const events = await Effect.runPromise(runToArray(Toolkit.make(t), [toolCall("t", "1")]))
350
+ const result = events.filter(isOutput)[0]?.result
351
+ expect(result).toMatchObject({ _tag: "Failure", kind: "not_found", reason: "missing" })
352
+ })
353
+
354
+ it("propagates a typed non-sentinel failure on the stream", async () => {
355
+ const t = Tool.make({
356
+ name: "t",
357
+ description: "",
358
+ inputSchema: Tool.fromEffectSchema(Empty),
359
+ run: () => Effect.fail(new AiError.RateLimited({ provider: "x", raw: null })),
360
+ })
361
+ const exit = await Effect.runPromiseExit(runToArray(Toolkit.make(t), [toolCall("t", "1")]))
362
+ expect(exit._tag).toBe("Failure")
363
+ })
364
+
365
+ it("dies on a defect instead of masking it as a result", async () => {
366
+ const t = Tool.make({
367
+ name: "t",
368
+ description: "",
369
+ inputSchema: Tool.fromEffectSchema(Empty),
370
+ run: () => Effect.die(new Error("boom")),
371
+ })
372
+ const exit = await Effect.runPromiseExit(runToArray(Toolkit.make(t), [toolCall("t", "1")]))
373
+ expect(exit._tag).toBe("Failure")
374
+ })
375
+
376
+ it("streams progress events strictly before the terminal output", async () => {
377
+ const t = Tool.make({
378
+ name: "t",
379
+ description: "",
380
+ inputSchema: Tool.fromEffectSchema(Empty),
381
+ run: (_input, emit) =>
382
+ emit("a").pipe(Effect.andThen(emit("b")), Effect.andThen(Effect.succeed("done"))),
383
+ })
384
+ const events = await Effect.runPromise(runToArray(Toolkit.make(t), [toolCall("t", "1")]))
385
+ expect(events.map((e) => e._tag)).toEqual(["Progress", "Progress", "Output"])
386
+ expect(events.filter(isProgress).map((p) => p.data)).toEqual(["a", "b"])
387
+ })
388
+
389
+ it("reports input validation failures as a result carrying the issue detail", async () => {
390
+ const t = Tool.make({
391
+ name: "t",
392
+ description: "",
393
+ inputSchema: Tool.fromEffectSchema(Schema.Struct({ x: Schema.Number })),
394
+ run: () => Effect.succeed(1),
395
+ })
396
+ const events = await Effect.runPromise(
397
+ runToArray(Toolkit.make(t), [toolCall("t", "1", '{"x":"nope"}')]),
398
+ )
399
+ const result = events.filter(isOutput)[0]?.result
400
+ expect(result).toMatchObject({ _tag: "Failure", kind: "input_validation_error" })
401
+ expect(result !== undefined && isFailure(result) && result.reason).toBeTruthy()
402
+ })
403
+
404
+ it("normalizes empty arguments to {} for a zero-arg tool", async () => {
405
+ const events = await Effect.runPromise(
406
+ runToArray(Toolkit.make(plain("t", "ok")), [toolCall("t", "1", "")]),
407
+ )
408
+ const result = events.filter(isOutput)[0]?.result
409
+ expect(result).toMatchObject({ _tag: "Ok", value: "ok" })
410
+ })
411
+
412
+ it("reports unparseable arguments as input_validation_error", async () => {
413
+ const events = await Effect.runPromise(
414
+ runToArray(Toolkit.make(plain("t", "ok")), [toolCall("t", "1", "not json")]),
415
+ )
416
+ const result = events.filter(isOutput)[0]?.result
417
+ expect(result).toMatchObject({ _tag: "Failure", kind: "input_validation_error" })
418
+ })
419
+
420
+ it("a call named constructor is unknown_tool, not non_local_tool", async () => {
421
+ const events = await Effect.runPromise(
422
+ runToArray(Toolkit.make(plain("t", "ok")), [toolCall("constructor", "1")]),
423
+ )
424
+ const result = events.filter(isOutput)[0]?.result
425
+ expect(result).toMatchObject({ _tag: "Failure", kind: "unknown_tool" })
426
+ })
427
+
428
+ it('serializes an undefined output as "null" on the wire', async () => {
429
+ const t = Tool.make({
430
+ name: "t",
431
+ description: "",
432
+ inputSchema: Tool.fromEffectSchema(Empty),
433
+ run: () => Effect.void,
434
+ })
435
+ const events = await Effect.runPromise(runToArray(Toolkit.make(t), [toolCall("t", "1")]))
436
+ const result = events.filter(isOutput)[0]?.result
437
+ expect(result !== undefined && toToolCallOutput(result).output).toBe("null")
438
+ })
439
+
440
+ it("preserves call order with concurrency 1", async () => {
441
+ const kit = Toolkit.make(plain("a", "A"), plain("b", "B"))
442
+ const events = await Effect.runPromise(
443
+ runToArray(kit, [toolCall("a", "a1"), toolCall("b", "b1")], { concurrency: 1 }),
444
+ )
445
+ expect(events.filter(isOutput).map((e) => e.result.call_id)).toEqual(["a1", "b1"])
446
+ })
447
+
448
+ it("type: run of a describeFailures toolkit has no stream error", () => {
449
+ const t = Tool.make({
450
+ name: "t",
451
+ description: "",
452
+ inputSchema: Tool.fromEffectSchema(Empty),
453
+ run: () => Effect.fail(new AiError.RateLimited({ provider: "x", raw: null })),
454
+ })
455
+ const kit = Toolkit.describeFailures(Toolkit.make(t), AiError.describe)
456
+ const stream = Toolkit.run(kit, [])
457
+ expectTypeOf(stream).toEqualTypeOf<Stream.Stream<ToolEvent, never, never>>()
458
+ })
459
+
460
+ it("type: run of a typed toolkit propagates the tool's E", () => {
461
+ const t = Tool.make({
462
+ name: "t",
463
+ description: "",
464
+ inputSchema: Tool.fromEffectSchema(Empty),
465
+ run: () => Effect.fail(new AiError.RateLimited({ provider: "x", raw: null })),
466
+ })
467
+ const stream = Toolkit.run(Toolkit.make(t), [])
468
+ expectTypeOf(stream).toEqualTypeOf<Stream.Stream<ToolEvent, AiError.RateLimited, never>>()
469
+ })
470
+ })