@effect-uai/core 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/dist/{AiError-CAX_48RU.d.mts → AiError-CrdR1EQi.d.mts} +2 -2
  2. package/dist/{AiError-CAX_48RU.d.mts.map → AiError-CrdR1EQi.d.mts.map} +1 -1
  3. package/dist/{Audio-DPlTsNdz.d.mts → Audio-DDSKCkSe.d.mts} +2 -2
  4. package/dist/{Audio-DPlTsNdz.d.mts.map → Audio-DDSKCkSe.d.mts.map} +1 -1
  5. package/dist/{Image-HNmMpMTh.d.mts → Image-C_Zjpwhc.d.mts} +2 -2
  6. package/dist/{Image-HNmMpMTh.d.mts.map → Image-C_Zjpwhc.d.mts.map} +1 -1
  7. package/dist/{Items-D8UaXQx_.d.mts → Items-CgKD6Npw.d.mts} +5 -5
  8. package/dist/{Items-D8UaXQx_.d.mts.map → Items-CgKD6Npw.d.mts.map} +1 -1
  9. package/dist/Loop-B5_cqumi.d.mts +173 -0
  10. package/dist/Loop-B5_cqumi.d.mts.map +1 -0
  11. package/dist/{Media-D_CpcM1Z.d.mts → Media-DOLyaygI.d.mts} +1 -1
  12. package/dist/{Media-D_CpcM1Z.d.mts.map → Media-DOLyaygI.d.mts.map} +1 -1
  13. package/dist/{SandboxError-BvKypkJm.d.mts → SandboxError-CIrVDOMV.d.mts} +2 -2
  14. package/dist/{SandboxError-BvKypkJm.d.mts.map → SandboxError-CIrVDOMV.d.mts.map} +1 -1
  15. package/dist/{StructuredFormat-BbN4dosH.d.mts → StructuredFormat-CMJFD3sn.d.mts} +2 -2
  16. package/dist/{StructuredFormat-BbN4dosH.d.mts.map → StructuredFormat-CMJFD3sn.d.mts.map} +1 -1
  17. package/dist/Tool-0XUIT2A2.d.mts +232 -0
  18. package/dist/Tool-0XUIT2A2.d.mts.map +1 -0
  19. package/dist/ToolEvent-DYrAUJGs.d.mts +151 -0
  20. package/dist/ToolEvent-DYrAUJGs.d.mts.map +1 -0
  21. package/dist/ToolResult-BZd-cEWW.d.mts +151 -0
  22. package/dist/ToolResult-BZd-cEWW.d.mts.map +1 -0
  23. package/dist/Toolkit-BKC8b8Xo.d.mts +155 -0
  24. package/dist/Toolkit-BKC8b8Xo.d.mts.map +1 -0
  25. package/dist/{dist-DV5ISja1.mjs → dist-B04dlFHg.mjs} +3 -3
  26. package/dist/{dist-DV5ISja1.mjs.map → dist-B04dlFHg.mjs.map} +1 -1
  27. package/dist/dist-DWgRbDN3.mjs +140 -0
  28. package/dist/dist-DWgRbDN3.mjs.map +1 -0
  29. package/dist/domain/AiError.d.mts +1 -1
  30. package/dist/domain/Audio.d.mts +1 -1
  31. package/dist/domain/Image.d.mts +1 -1
  32. package/dist/domain/Items.d.mts +1 -1
  33. package/dist/domain/Media.d.mts +1 -1
  34. package/dist/domain/Music.d.mts +1 -1
  35. package/dist/domain/Turn.d.mts +4 -4
  36. package/dist/domain/Turn.test.mjs +1 -1
  37. package/dist/embedding-model/Embedding.d.mts +1 -1
  38. package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
  39. package/dist/embedding-model/EmbeddingModel.test.mjs +1 -1
  40. package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -1
  41. package/dist/index.d.mts +16 -13
  42. package/dist/index.mjs +5 -2
  43. package/dist/language-model/LanguageModel.d.mts +10 -5
  44. package/dist/language-model/LanguageModel.d.mts.map +1 -1
  45. package/dist/language-model/LanguageModel.mjs.map +1 -1
  46. package/dist/language-model/LanguageModel.test.mjs +1 -1
  47. package/dist/loop/Loop.d.mts +2 -141
  48. package/dist/loop/Loop.mjs +16 -20
  49. package/dist/loop/Loop.mjs.map +1 -1
  50. package/dist/loop/Loop.test.mjs +18 -1
  51. package/dist/loop/Loop.test.mjs.map +1 -1
  52. package/dist/{magic-string.es-BgIV5Mu3.mjs → magic-string.es-Dk-MMA0F.mjs} +1 -1
  53. package/dist/{magic-string.es-BgIV5Mu3.mjs.map → magic-string.es-Dk-MMA0F.mjs.map} +1 -1
  54. package/dist/music-generator/MusicGenerator.d.mts +2 -2
  55. package/dist/music-generator/MusicGenerator.test.mjs +1 -1
  56. package/dist/observability/Metrics.d.mts +116 -28
  57. package/dist/observability/Metrics.d.mts.map +1 -1
  58. package/dist/observability/Metrics.mjs +250 -38
  59. package/dist/observability/Metrics.mjs.map +1 -1
  60. package/dist/observability/Metrics.test.d.mts +1 -0
  61. package/dist/observability/Metrics.test.mjs +187 -0
  62. package/dist/observability/Metrics.test.mjs.map +1 -0
  63. package/dist/observability/Telemetry.d.mts +37 -0
  64. package/dist/observability/Telemetry.d.mts.map +1 -0
  65. package/dist/observability/Telemetry.mjs +57 -0
  66. package/dist/observability/Telemetry.mjs.map +1 -0
  67. package/dist/observability/Telemetry.test.d.mts +1 -0
  68. package/dist/observability/Telemetry.test.mjs +92 -0
  69. package/dist/observability/Telemetry.test.mjs.map +1 -0
  70. package/dist/retry/Retry.d.mts +2 -2
  71. package/dist/retry/Retry.test.mjs +1 -1
  72. package/dist/sandbox/Sandbox.d.mts +1 -1
  73. package/dist/sandbox/SandboxError.d.mts +1 -1
  74. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +2 -2
  75. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +1 -1
  76. package/dist/streaming/JSONL.d.mts +1 -1
  77. package/dist/streaming/JSONL.test.mjs +1 -1
  78. package/dist/streaming/SSE.test.mjs +1 -1
  79. package/dist/structured-format/StructuredFormat.d.mts +1 -1
  80. package/dist/structured-format/StructuredFormat.mjs.map +1 -1
  81. package/dist/structured-format/StructuredFormat.test.mjs +1 -1
  82. package/dist/testing/MockMusicGenerator.d.mts +1 -1
  83. package/dist/testing/MockProvider.d.mts +1 -1
  84. package/dist/testing/MockSpeechSynthesizer.d.mts +1 -1
  85. package/dist/tool/Approval.d.mts +3 -3
  86. package/dist/tool/Approval.test.mjs +35 -34
  87. package/dist/tool/Approval.test.mjs.map +1 -1
  88. package/dist/tool/HistoryCheck.d.mts +2 -2
  89. package/dist/tool/Tool.d.mts +2 -2
  90. package/dist/tool/Tool.mjs +112 -22
  91. package/dist/tool/Tool.mjs.map +1 -1
  92. package/dist/tool/Tool.test.mjs +41 -3
  93. package/dist/tool/Tool.test.mjs.map +1 -1
  94. package/dist/tool/ToolEvent.d.mts +2 -151
  95. package/dist/tool/ToolResult.d.mts +2 -138
  96. package/dist/tool/ToolResult.mjs +18 -3
  97. package/dist/tool/ToolResult.mjs.map +1 -1
  98. package/dist/tool/Toolkit.d.mts +2 -73
  99. package/dist/tool/Toolkit.mjs +106 -28
  100. package/dist/tool/Toolkit.mjs.map +1 -1
  101. package/dist/tool/Toolkit.test.mjs +113 -7
  102. package/dist/tool/Toolkit.test.mjs.map +1 -1
  103. package/dist/transcriber/Transcriber.d.mts +2 -2
  104. package/dist/transcriber/Transcriber.test.mjs +1 -1
  105. package/dist/web-search/WebSearch.d.mts +119 -0
  106. package/dist/web-search/WebSearch.d.mts.map +1 -0
  107. package/dist/web-search/WebSearch.mjs +42 -0
  108. package/dist/web-search/WebSearch.mjs.map +1 -0
  109. package/dist/web-search/WebSearchTool.d.mts +58 -0
  110. package/dist/web-search/WebSearchTool.d.mts.map +1 -0
  111. package/dist/web-search/WebSearchTool.mjs +69 -0
  112. package/dist/web-search/WebSearchTool.mjs.map +1 -0
  113. package/package.json +9 -1
  114. package/src/embedding-model/EmbeddingModel.test.ts +4 -0
  115. package/src/index.ts +3 -0
  116. package/src/language-model/LanguageModel.ts +7 -2
  117. package/src/loop/Loop.test.ts +22 -0
  118. package/src/loop/Loop.ts +27 -4
  119. package/src/observability/Metrics.test.ts +266 -0
  120. package/src/observability/Metrics.ts +505 -65
  121. package/src/observability/Telemetry.test.ts +123 -0
  122. package/src/observability/Telemetry.ts +96 -0
  123. package/src/structured-format/StructuredFormat.ts +4 -0
  124. package/src/tool/Approval.test.ts +22 -21
  125. package/src/tool/Tool.test.ts +55 -2
  126. package/src/tool/Tool.ts +221 -55
  127. package/src/tool/ToolResult.ts +22 -1
  128. package/src/tool/Toolkit.test.ts +168 -7
  129. package/src/tool/Toolkit.ts +336 -99
  130. package/src/web-search/WebSearch.ts +150 -0
  131. package/src/web-search/WebSearchTool.ts +106 -0
  132. package/dist/Tool-CiFXYjmR.d.mts +0 -102
  133. package/dist/Tool-CiFXYjmR.d.mts.map +0 -1
  134. package/dist/loop/Loop.d.mts.map +0 -1
  135. package/dist/tool/ToolEvent.d.mts.map +0 -1
  136. package/dist/tool/ToolResult.d.mts.map +0 -1
  137. package/dist/tool/Toolkit.d.mts.map +0 -1
package/src/tool/Tool.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
2
- import { Effect, Schema, Stream } from "effect"
2
+ import { Effect, Schema } from "effect"
3
3
  import type { ToolCall, ToolCallOutput } from "../domain/Items.js"
4
4
  import { toolCallOutput } from "../domain/Items.js"
5
5
 
@@ -13,6 +13,30 @@ export class ToolError extends Schema.TaggedErrorClass<ToolError>("@betalyra/eff
13
13
  },
14
14
  ) {}
15
15
 
16
+ /**
17
+ * One Standard Schema validation issue, carried on `ToolValidationError`.
18
+ * Mirrors `StandardSchemaV1.Issue`; `path` is left as `unknown` elements
19
+ * because Standard Schema path segments may be symbols or `{ key }` objects.
20
+ */
21
+ const ToolValidationIssue = Schema.Struct({
22
+ message: Schema.String,
23
+ path: Schema.optional(Schema.Array(Schema.Unknown)),
24
+ })
25
+
26
+ /**
27
+ * The model's `arguments` for a call failed the tool's `inputSchema`. Distinct
28
+ * from `ToolError` (malformed JSON, runtime crash) so callers can tell a
29
+ * contract violation apart from a parse or execution failure, and so the
30
+ * structured `issues` survive instead of being flattened into a message string.
31
+ */
32
+ export class ToolValidationError extends Schema.TaggedErrorClass<ToolValidationError>(
33
+ "@betalyra/effect-uai/ToolValidationError",
34
+ )("ToolValidationError", {
35
+ call_id: Schema.String,
36
+ tool: Schema.String,
37
+ issues: Schema.Array(ToolValidationIssue),
38
+ }) {}
39
+
16
40
  /**
17
41
  * Schemas accepted on `Tool.inputSchema`. Must implement both Standard
18
42
  * Schema validation and JSON Schema conversion (for rendering tool
@@ -33,8 +57,7 @@ export type ToolInputSchema<Input = unknown> = StandardSchemaV1<unknown, Input>
33
57
  export const fromEffectSchema = <S extends Schema.Codec<any, any, never, any>>(
34
58
  schema: S,
35
59
  ): S & ToolInputSchema<S["Type"]> =>
36
- Schema.toStandardJSONSchemaV1(Schema.toStandardSchemaV1(schema)) as unknown as S &
37
- ToolInputSchema<S["Type"]>
60
+ Schema.toStandardJSONSchemaV1(Schema.toStandardSchemaV1(schema))
38
61
 
39
62
  /**
40
63
  * Use any schema library that implements both Standard Schema (validation)
@@ -56,11 +79,37 @@ export const fromStandardSchema = <S extends StandardSchemaV1 & StandardJSONSche
56
79
  ): S & ToolInputSchema<StandardSchemaV1.InferOutput<S>> =>
57
80
  schema as S & ToolInputSchema<StandardSchemaV1.InferOutput<S>>
58
81
 
59
- export type Tool<Name extends string, Input, Output, R = never> = {
82
+ /**
83
+ * Emit one intermediate `Event` from inside a tool's `run`. Backed by the
84
+ * executor's per-call queue, so it returns an `Effect` (backpressure) and
85
+ * composes with `Stream.runForEach(emit)`.
86
+ */
87
+ export type Emit<Event> = (event: Event) => Effect.Effect<void>
88
+
89
+ /**
90
+ * A model-visible tool with a local `Effect` executor. `run` computes the
91
+ * model-facing `Output`; emitting progress is a side channel via `emit`. A
92
+ * plain tool never calls `emit` (its `Event` is irrelevant); a streaming tool
93
+ * emits intermediate `Event`s that flow to the consumer as
94
+ * `ToolEvent.Progress` in real time.
95
+ *
96
+ * This is one of four tool *kinds*, discriminated by `_tag`. The other three
97
+ * (`ProviderTool`, `SignalTool`, `InteractionTool`) are model-visible but have
98
+ * no local executor — see their constructors below. `Tool` keeps its name (and
99
+ * the `Tool.make` constructor) because the local kind is by far the common one.
100
+ */
101
+ export type Tool<Name extends string, Input, Event, Output, R = never> = {
102
+ readonly _tag: "LocalTool"
60
103
  readonly name: Name
61
104
  readonly description: string
62
105
  readonly inputSchema: ToolInputSchema<Input>
63
- readonly run: (input: Input) => Effect.Effect<Output, unknown, R>
106
+ readonly run: (input: Input, emit: Emit<Event>) => Effect.Effect<Output, unknown, R>
107
+ /**
108
+ * Bound on this tool's emit queue. Unbounded when omitted. Per-tool because
109
+ * backpressure depends on how a given tool emits, unlike `concurrency`
110
+ * (cross-tool, on `Toolkit.run`'s options).
111
+ */
112
+ readonly emitBufferSize?: number
64
113
  /**
65
114
  * Whether the provider should render this tool with its strict-mode
66
115
  * flag (OpenAI's `strict: true`, etc). Default: true. The framework
@@ -70,6 +119,67 @@ export type Tool<Name extends string, Input, Output, R = never> = {
70
119
  readonly strict?: boolean
71
120
  }
72
121
 
122
+ /**
123
+ * A provider-hosted tool: visible to the model and executed by the *provider*
124
+ * (native web search, code execution, RAG grounding), never by this process.
125
+ * It has no local `run`; `provider`/`config` tell the provider adapter how to
126
+ * render it. Passing it to `Toolkit.run` yields a `non_local_tool` result.
127
+ */
128
+ export type ProviderTool<Name extends string, Input, Provider extends string, Config> = {
129
+ readonly _tag: "ProviderTool"
130
+ readonly name: Name
131
+ readonly description: string
132
+ readonly inputSchema: ToolInputSchema<Input>
133
+ readonly provider: Provider
134
+ readonly config: Config
135
+ readonly strict?: boolean
136
+ }
137
+
138
+ /**
139
+ * A typed request for the agent loop to change control flow (escalate, pause,
140
+ * schedule, hand off). Model-visible and decodable (`decodeArgs`) but never
141
+ * locally executed — the loop intercepts the call in `onTurnComplete` and acts
142
+ * on it, so there is no fake `run`. `Toolkit.run` reports it as `non_local_tool`.
143
+ */
144
+ export type SignalTool<Name extends string, Input> = {
145
+ readonly _tag: "SignalTool"
146
+ readonly name: Name
147
+ readonly description: string
148
+ readonly inputSchema: ToolInputSchema<Input>
149
+ }
150
+
151
+ /**
152
+ * A typed request for an external actor (user, channel, frontend) to respond
153
+ * before the loop resumes. Like `SignalTool` it is decode-only, but its
154
+ * lifecycle differs: the loop usually stops/pauses and resumes later by
155
+ * appending a `function_call_output` once the actor replies.
156
+ */
157
+ export type InteractionTool<Name extends string, Input> = {
158
+ readonly _tag: "InteractionTool"
159
+ readonly name: Name
160
+ readonly description: string
161
+ readonly inputSchema: ToolInputSchema<Input>
162
+ }
163
+
164
+ /** A local (executable) tool of any shape. Readability alias. */
165
+ export type AnyLocalTool<R = any> = Tool<string, any, any, any, R>
166
+
167
+ /** Any of the four model-visible tool kinds. */
168
+ export type AnyTool<R = any> =
169
+ | Tool<string, any, any, any, R>
170
+ | ProviderTool<string, any, string, any>
171
+ | SignalTool<string, any>
172
+ | InteractionTool<string, any>
173
+
174
+ /** A model-visible tool that carries a decodable input schema (every kind). */
175
+ export type DecodableTool<Input> = {
176
+ readonly name: string
177
+ readonly inputSchema: ToolInputSchema<Input>
178
+ }
179
+
180
+ /** 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
182
+
73
183
  /**
74
184
  * Provider-agnostic tool descriptor. Each provider maps `inputSchema`
75
185
  * to its own wire field (OpenAI → `parameters`, Anthropic →
@@ -82,69 +192,111 @@ export type ToolDescriptor = {
82
192
  readonly strict?: boolean
83
193
  }
84
194
 
85
- export const make = <Name extends string, Input, Output, R = never>(
86
- spec: Tool<Name, Input, Output, R>,
87
- ): Tool<Name, Input, Output, R> => spec
88
-
89
- // ---------------------------------------------------------------------------
90
- // Streaming tools
91
- //
92
- // `run` returns a `Stream<Event>` instead of an `Effect<Output>`. Events
93
- // flow through to the consumer as `ToolEvent.Progress`s in real time;
94
- // at end-of-stream `finalize(events)` reduces them to the model-facing
95
- // `Output`. Sub-agents, slow downloads with progress, recipe streamers.
96
- // ---------------------------------------------------------------------------
97
-
98
- export type StreamingTool<Name extends string, Input, Event, Output, R = never> = {
99
- readonly _kind: "streaming"
100
- readonly name: Name
101
- readonly description: string
102
- readonly inputSchema: ToolInputSchema<Input>
103
- readonly run: (input: Input) => Stream.Stream<Event, unknown, R>
104
- readonly finalize: (events: ReadonlyArray<Event>) => Output
105
- readonly strict?: boolean
106
- }
195
+ /** 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 })
107
199
 
108
- export const streaming = <Name extends string, Input, Event, Output, R = never>(
109
- spec: Omit<StreamingTool<Name, Input, Event, Output, R>, "_kind">,
110
- ): StreamingTool<Name, Input, Event, Output, R> => ({ _kind: "streaming", ...spec })
200
+ /**
201
+ * A provider-hosted tool (native web search, code execution, RAG grounding).
202
+ * No local executor — `provider`/`config` drive the provider adapter's
203
+ * rendering. The model sees it; `Toolkit.run` reports it as `non_local_tool`.
204
+ */
205
+ export const provider = <Name extends string, Input, Provider extends string, Config>(
206
+ spec: Omit<ProviderTool<Name, Input, Provider, Config>, "_tag">,
207
+ ): ProviderTool<Name, Input, Provider, Config> => ({ _tag: "ProviderTool", ...spec })
111
208
 
112
- export type AnyStreamingTool<R = any> = StreamingTool<string, any, any, any, R>
113
- export type AnyPlainTool<R = any> = Tool<string, any, any, R>
114
- export type AnyTool<R = any> = AnyStreamingTool<R> | AnyPlainTool<R>
209
+ /**
210
+ * A typed control-flow signal to the agent loop (escalate, pause, schedule,
211
+ * hand off). Decode-only: the loop interprets the call rather than executing a
212
+ * handler, so there is no `run`.
213
+ */
214
+ export const signal = <Name extends string, Input>(
215
+ spec: Omit<SignalTool<Name, Input>, "_tag">,
216
+ ): SignalTool<Name, Input> => ({ _tag: "SignalTool", ...spec })
217
+
218
+ /**
219
+ * A typed request for an external actor to respond before the loop resumes
220
+ * (frontend, Telegram, CLI prompt). Decode-only; the loop stops/pauses and
221
+ * resumes by appending the actor's `function_call_output`.
222
+ */
223
+ export const interaction = <Name extends string, Input>(
224
+ spec: Omit<InteractionTool<Name, Input>, "_tag">,
225
+ ): InteractionTool<Name, Input> => ({ _tag: "InteractionTool", ...spec })
115
226
 
116
- export const isStreamingTool = <R>(t: AnyTool<R>): t is AnyStreamingTool<R> =>
117
- "_kind" in t && t._kind === "streaming"
227
+ /**
228
+ * Return a copy of a tool under a new `name`. Useful for resolving a name
229
+ * clash before combining tools into one `Toolkit`. Literal-preserving, so the
230
+ * renamed tool keys the toolkit record under the new name statically.
231
+ */
232
+ export const withName = <N extends string, T extends AnyTool>(
233
+ tool: T,
234
+ name: N,
235
+ ): Omit<T, "name"> & { readonly name: N } => ({ ...tool, name })
118
236
 
119
237
  /**
120
- * Render any-kind tools (mixed plain and streaming) to provider-agnostic
121
- * descriptors. Accepts the union type so a single list can carry both
122
- * plain and streaming tools.
238
+ * Replace a local tool's `run`, keeping its model-facing definition (name,
239
+ * description, schema, `_tag`) identical. The new `run`'s `input` is typed from
240
+ * the original tool, so override/mock need no annotation:
241
+ *
242
+ * const safe = { ...toolkit, send_email: Tool.withRun(toolkit.send_email,
243
+ * ({ to }) => Effect.succeed({ status: "dry-run", to })) }
244
+ *
245
+ * Descriptors stay identical to production, so a mocked toolkit can't drift
246
+ * from the contract the model sees.
123
247
  */
124
- export const toDescriptors = <R>(tools: ReadonlyArray<AnyTool<R>>): ReadonlyArray<ToolDescriptor> =>
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 })
252
+
253
+ /** Render tools to provider-agnostic descriptors. Every kind is model-visible. */
254
+ export const toDescriptors = (tools: ReadonlyArray<AnyTool>): ReadonlyArray<ToolDescriptor> =>
125
255
  tools.map((tool) => {
126
256
  const inputSchema = tool.inputSchema["~standard"].jsonSchema.input({
127
257
  target: "draft-2020-12",
128
258
  })
129
- return tool.strict !== undefined
130
- ? { name: tool.name, description: tool.description, inputSchema, strict: tool.strict }
259
+ // `strict` only exists on local/provider kinds; signals/interactions omit it.
260
+ const strict = "strict" in tool ? tool.strict : undefined
261
+ return strict !== undefined
262
+ ? { name: tool.name, description: tool.description, inputSchema, strict }
131
263
  : { name: tool.name, description: tool.description, inputSchema }
132
264
  })
133
265
 
266
+ /**
267
+ * Render a `Toolkit` (a name-indexed record of tools) to descriptors, treating
268
+ * an absent toolkit as no tools. The normalization point the `LanguageModel`
269
+ * boundary uses so a request can carry the toolkit itself instead of a
270
+ * pre-rendered descriptor array. Lives here (not on `Toolkit`) to keep providers
271
+ * free of a dependency on the toolkit module — it only needs the tool values.
272
+ */
273
+ export const descriptorsOf = (
274
+ toolkit?: Readonly<Record<string, AnyTool>>,
275
+ ): ReadonlyArray<ToolDescriptor> =>
276
+ toolkit === undefined ? [] : toDescriptors(Object.values(toolkit))
277
+
134
278
  const toToolError = (call: ToolCall, toolName: string, message: string) => (cause: unknown) =>
135
279
  new ToolError({ call_id: call.call_id, tool: toolName, message, cause })
136
280
 
137
281
  /**
138
- * Decode and validate the JSON arguments of a function_call against the
139
- * tool's input schema, run the tool, and serialize the output into a
140
- * function_call_output item.
282
+ * Decode and validate a function_call's JSON `arguments` against the tool's
283
+ * own `inputSchema`, yielding the typed input. The decode-only half of
284
+ * `execute` - reach for it when you intercept a call to translate it into a
285
+ * control-flow decision rather than running the tool's `run`.
286
+ *
287
+ * Malformed JSON fails with `ToolError`; a parsed body that violates
288
+ * `inputSchema` fails with `ToolValidationError` carrying the issues.
141
289
  */
142
- export const execute = <Name extends string, Input, Output, R>(
143
- tool: Tool<Name, Input, Output, R>,
290
+ export const decodeArgs = <Input>(
291
+ tool: DecodableTool<Input>,
144
292
  call: ToolCall,
145
- ): Effect.Effect<ToolCallOutput, ToolError, R> =>
293
+ ): Effect.Effect<Input, ToolError | ToolValidationError> =>
146
294
  Effect.gen(function* () {
147
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
148
300
  try: () => JSON.parse(call.arguments) as unknown,
149
301
  catch: toToolError(call, tool.name, "Failed to parse JSON arguments"),
150
302
  })
@@ -153,16 +305,30 @@ export const execute = <Name extends string, Input, Output, R>(
153
305
  Promise.resolve(tool.inputSchema["~standard"].validate(parsed)),
154
306
  )
155
307
  if (result.issues !== undefined) {
156
- return yield* new ToolError({
308
+ return yield* new ToolValidationError({
157
309
  call_id: call.call_id,
158
310
  tool: tool.name,
159
- message: "Tool input failed schema validation",
160
- cause: result.issues,
311
+ issues: result.issues,
161
312
  })
162
313
  }
163
-
164
- const output = yield* tool
165
- .run(result.value)
166
- .pipe(Effect.mapError(toToolError(call, tool.name, "Tool execution failed")))
167
- return toolCallOutput(call.call_id, JSON.stringify(output))
314
+ return result.value
168
315
  })
316
+
317
+ /**
318
+ * Decode and validate the JSON arguments of a function_call against the
319
+ * tool's input schema, run the tool, and serialize the output into a
320
+ * function_call_output item. Single-shot: intermediate events emitted by
321
+ * `run` are discarded (use `Toolkit.run` to stream them).
322
+ */
323
+ export const execute = <Name extends string, Input, Event, Output, R>(
324
+ tool: Tool<Name, Input, Event, Output, R>,
325
+ call: ToolCall,
326
+ ): Effect.Effect<ToolCallOutput, ToolError | ToolValidationError, R> =>
327
+ 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))),
334
+ )
@@ -66,10 +66,31 @@ export const denied = (call: ToolCall, reason?: string): ToolResult =>
66
66
  export const cancelled = (call: ToolCall, reason?: string): ToolResult =>
67
67
  failed(call, "cancelled", reason)
68
68
 
69
- /** Tool's own execution failed (parse error, schema, runtime crash). */
69
+ /** Tool's own execution failed (JSON parse error, runtime crash). */
70
70
  export const executionError = (call: ToolCall, reason: string): ToolResult =>
71
71
  failed(call, "execution_error", reason)
72
72
 
73
+ /**
74
+ * The model called a tool that is visible to it but has no local executor — a
75
+ * provider-hosted, signal, or interaction tool routed through `Toolkit.run`.
76
+ * Distinct from `unknown_tool` (no such tool at all): the loop is expected to
77
+ * intercept these kinds before execution, so this kind flags a missing handler.
78
+ */
79
+ export const nonLocalTool = (call: ToolCall, reason?: string): ToolResult =>
80
+ failed(
81
+ call,
82
+ "non_local_tool",
83
+ reason ?? `Tool "${call.name}" is visible to the model but has no local executor`,
84
+ )
85
+
86
+ /**
87
+ * The model's `arguments` failed the tool's input schema. Distinct kind from
88
+ * `execution_error` so recipes can tell a contract violation apart from a
89
+ * runtime crash.
90
+ */
91
+ export const validationError = (call: ToolCall, reason?: string): ToolResult =>
92
+ failed(call, "input_validation_error", reason ?? "Tool input failed schema validation")
93
+
73
94
  // ---------------------------------------------------------------------------
74
95
  // Wire conversion - the one place structured → string happens.
75
96
  // ---------------------------------------------------------------------------
@@ -1,4 +1,4 @@
1
- import { Context, Effect, Layer, Schema, Stream } from "effect"
1
+ import { Context, Effect, Layer, pipe, Schema, Stream } from "effect"
2
2
  import { describe, expect, expectTypeOf, it } from "vitest"
3
3
  import type { ToolCall } from "../domain/Items.js"
4
4
  import { isOutput } from "./ToolEvent.js"
@@ -92,7 +92,7 @@ describe("Toolkit.run - tools with R requirements", () => {
92
92
  })
93
93
 
94
94
  it("propagates each tool's R into the resulting Stream's requirements", () => {
95
- const stream = Toolkit.run([getWeather, getCoords], [])
95
+ const stream = Toolkit.run(Toolkit.make(getWeather, getCoords), [])
96
96
  expectTypeOf(stream).toEqualTypeOf<
97
97
  Stream.Stream<import("./ToolEvent.js").ToolEvent, never, WeatherApiKey | GeoApiKey>
98
98
  >()
@@ -104,10 +104,10 @@ describe("Toolkit.run - tools with R requirements", () => {
104
104
  Layer.succeed(GeoApiKey, { key: "geo-456" }),
105
105
  )
106
106
 
107
- const program = Toolkit.run(
108
- [getWeather, getCoords],
109
- [call("get_weather", "c1"), call("get_coords", "c2")],
110
- ).pipe(Stream.runCollect, Effect.provide(layer))
107
+ const program = Toolkit.run(Toolkit.make(getWeather, getCoords), [
108
+ call("get_weather", "c1"),
109
+ call("get_coords", "c2"),
110
+ ]).pipe(Stream.runCollect, Effect.provide(layer))
111
111
 
112
112
  const events = await Effect.runPromise(program)
113
113
  const outputs = Array.from(events).filter(isOutput)
@@ -125,6 +125,43 @@ describe("Toolkit.run - tools with R requirements", () => {
125
125
  })
126
126
  })
127
127
 
128
+ it("reports non-local kinds as non_local_tool while local calls in the same turn still run", async () => {
129
+ const escalate = Tool.signal({
130
+ name: "escalate",
131
+ description: "",
132
+ inputSchema: Tool.fromEffectSchema(Empty),
133
+ })
134
+ const localPlain = Tool.make({
135
+ name: "plain",
136
+ description: "",
137
+ inputSchema: Tool.fromEffectSchema(Empty),
138
+ run: () => Effect.succeed({ ok: true }),
139
+ })
140
+
141
+ const events = await Effect.runPromise(
142
+ Toolkit.run(Toolkit.make(localPlain, escalate), [
143
+ call("escalate", "s1"),
144
+ call("plain", "p1"),
145
+ call("ghost", "g1"),
146
+ ]).pipe(Stream.runCollect),
147
+ )
148
+ const byCall = new Map(
149
+ Array.from(events)
150
+ .filter(isOutput)
151
+ .map((e) => [e.result.call_id, e.result] as const),
152
+ )
153
+
154
+ // Signal tool: model-visible but no local executor.
155
+ const s = byCall.get("s1")
156
+ expect(s?._tag === "Failure" && s.kind).toBe("non_local_tool")
157
+ // Unknown tool stays distinct from non-local.
158
+ const g = byCall.get("g1")
159
+ expect(g?._tag === "Failure" && g.kind).toBe("unknown_tool")
160
+ // The local call still executed despite the non-local sibling.
161
+ const p = byCall.get("p1")
162
+ expect(p !== undefined && isOk(p) && p.value).toEqual({ ok: true })
163
+ })
164
+
128
165
  it("with no service-needing tools, R is never", () => {
129
166
  const plain = Tool.make({
130
167
  name: "plain",
@@ -132,9 +169,133 @@ describe("Toolkit.run - tools with R requirements", () => {
132
169
  inputSchema: Tool.fromEffectSchema(Empty),
133
170
  run: () => Effect.succeed(0),
134
171
  })
135
- const stream = Toolkit.run([plain], [])
172
+ const stream = Toolkit.run(Toolkit.make(plain), [])
136
173
  expectTypeOf(stream).toEqualTypeOf<
137
174
  Stream.Stream<import("./ToolEvent.js").ToolEvent, never, never>
138
175
  >()
139
176
  })
140
177
  })
178
+
179
+ describe("Toolkit.make / compose / namespace - uniqueness", () => {
180
+ const Empty = Schema.Struct({})
181
+ const tool = (name: string) =>
182
+ Tool.make({
183
+ name,
184
+ description: "",
185
+ inputSchema: Tool.fromEffectSchema(Empty),
186
+ run: () => Effect.succeed(0),
187
+ })
188
+
189
+ it("rejects a duplicate literal name at compile time", () => {
190
+ const a = tool("search")
191
+ const b = Tool.provider({
192
+ name: "search",
193
+ description: "",
194
+ inputSchema: Tool.fromEffectSchema(Empty),
195
+ provider: "demo",
196
+ config: {},
197
+ })
198
+ // @ts-expect-error Duplicate tool name: search
199
+ Toolkit.make(a, b)
200
+ })
201
+
202
+ it("fails compose with DuplicateToolName naming the colliding sources (dynamic)", async () => {
203
+ const github = Toolkit.fromArray([tool("search")])
204
+ const linear = Toolkit.fromArray([tool("search")])
205
+ const exit = await Effect.runPromiseExit(Toolkit.compose(github, linear))
206
+ expect(exit._tag).toBe("Failure")
207
+ if (exit._tag === "Failure") {
208
+ const json = JSON.stringify(exit.cause)
209
+ expect(json).toContain("DuplicateToolName")
210
+ expect(json).toContain("toolkit-0")
211
+ expect(json).toContain("toolkit-1")
212
+ }
213
+ })
214
+
215
+ it("namespaces names so generic names from two sources compose cleanly", async () => {
216
+ const github = Toolkit.namespace("github", Toolkit.fromArray([tool("search")]))
217
+ const linear = Toolkit.namespace("linear", Toolkit.fromArray([tool("search")]))
218
+ const composed = await Effect.runPromise(Toolkit.compose(github, linear))
219
+ expect(Object.keys(composed).sort()).toEqual(["github__search", "linear__search"])
220
+ })
221
+
222
+ it("preserves each tool's R through compose", () => {
223
+ const stream = Effect.gen(function* () {
224
+ const composed = yield* Toolkit.compose(Toolkit.make(tool("a")), Toolkit.make(tool("b")))
225
+ return Toolkit.run(composed, [])
226
+ })
227
+ // No service-needing tools here -> R stays never (compose didn't poison it).
228
+ expectTypeOf(stream).toEqualTypeOf<
229
+ Effect.Effect<
230
+ Stream.Stream<import("./ToolEvent.js").ToolEvent, never, never>,
231
+ Toolkit.DuplicateToolName
232
+ >
233
+ >()
234
+ })
235
+ })
236
+
237
+ describe("Toolkit.wrap - middleware", () => {
238
+ const Empty = Schema.Struct({})
239
+ type AuditShape = { readonly record: (name: string) => Effect.Effect<void> }
240
+ class Audit extends Context.Service<Audit, AuditShape>()("test/Audit") {}
241
+
242
+ const getWeather = Tool.make({
243
+ name: "get_weather",
244
+ description: "",
245
+ inputSchema: Tool.fromEffectSchema(Empty),
246
+ run: () => Effect.succeed({ tempC: 18 }),
247
+ })
248
+ const escalate = Tool.signal({
249
+ name: "escalate",
250
+ description: "",
251
+ inputSchema: Tool.fromEffectSchema(Empty),
252
+ })
253
+
254
+ // A middleware that needs the Audit service -> R2 = Audit.
255
+ const audited: Toolkit.Middleware<Audit> = (run, name) => (input, emit) =>
256
+ Effect.gen(function* () {
257
+ const a = yield* Audit
258
+ yield* a.record(name)
259
+ return yield* run(input, emit)
260
+ })
261
+
262
+ const call = (name: string, id: string): ToolCall => ({
263
+ type: "function_call",
264
+ call_id: id,
265
+ name,
266
+ arguments: "{}",
267
+ })
268
+
269
+ it("unions the middleware's R2 into the toolkit's requirements", () => {
270
+ const wrapped = Toolkit.wrap(Toolkit.make(getWeather), audited)
271
+ const stream = Toolkit.run(wrapped, [])
272
+ expectTypeOf(stream).toEqualTypeOf<
273
+ Stream.Stream<import("./ToolEvent.js").ToolEvent, never, Audit>
274
+ >()
275
+ })
276
+
277
+ it("runs the wrapped handler and leaves non-local kinds untouched", async () => {
278
+ const recorded: Array<string> = []
279
+ const wrapped = pipe(Toolkit.make(getWeather, escalate), Toolkit.wrap(audited))
280
+
281
+ const events = await Effect.runPromise(
282
+ Toolkit.run(wrapped, [call("get_weather", "w1"), call("escalate", "s1")]).pipe(
283
+ Stream.runCollect,
284
+ Effect.provideService(Audit, { record: (n) => Effect.sync(() => void recorded.push(n)) }),
285
+ ),
286
+ )
287
+ const byCall = new Map(
288
+ Array.from(events)
289
+ .filter(isOutput)
290
+ .map((e) => [e.result.call_id, e.result] as const),
291
+ )
292
+
293
+ // Local tool ran through the middleware (audit fired).
294
+ expect(recorded).toEqual(["get_weather"])
295
+ const w = byCall.get("w1")
296
+ expect(w !== undefined && isOk(w) && w.value).toEqual({ tempC: 18 })
297
+ // Signal kind is left alone by wrap -> still reported non-local.
298
+ const s = byCall.get("s1")
299
+ expect(s?._tag === "Failure" && s.kind).toBe("non_local_tool")
300
+ })
301
+ })