@effect-uai/core 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist/{AiError-CAX_48RU.d.mts → AiError-Bf1pq4BB.d.mts} +9 -4
  2. package/dist/{AiError-CAX_48RU.d.mts.map → AiError-Bf1pq4BB.d.mts.map} +1 -1
  3. package/dist/{Audio-DPlTsNdz.d.mts → Audio-CR7CSljJ.d.mts} +1 -1
  4. package/dist/{Audio-DPlTsNdz.d.mts.map → Audio-CR7CSljJ.d.mts.map} +1 -1
  5. package/dist/{Image-HNmMpMTh.d.mts → Image-BZmKfIdq.d.mts} +1 -1
  6. package/dist/{Image-HNmMpMTh.d.mts.map → Image-BZmKfIdq.d.mts.map} +1 -1
  7. package/dist/{Items-7ljaVxH_.d.mts → Items-BnIduEV2.d.mts} +7 -7
  8. package/dist/{Items-7ljaVxH_.d.mts.map → Items-BnIduEV2.d.mts.map} +1 -1
  9. package/dist/Loop-D8nWpowc.d.mts +173 -0
  10. package/dist/Loop-D8nWpowc.d.mts.map +1 -0
  11. package/dist/{SandboxError-BvKypkJm.d.mts → SandboxError-CIrVDOMV.d.mts} +2 -2
  12. package/dist/{SandboxError-BvKypkJm.d.mts.map → SandboxError-CIrVDOMV.d.mts.map} +1 -1
  13. package/dist/{StructuredFormat-BbN4dosH.d.mts → StructuredFormat-DHaUJBdd.d.mts} +2 -2
  14. package/dist/{StructuredFormat-BbN4dosH.d.mts.map → StructuredFormat-DHaUJBdd.d.mts.map} +1 -1
  15. package/dist/Tool-2lC_XMth.d.mts +284 -0
  16. package/dist/Tool-2lC_XMth.d.mts.map +1 -0
  17. package/dist/ToolEvent-cWZ2BnbS.d.mts +151 -0
  18. package/dist/ToolEvent-cWZ2BnbS.d.mts.map +1 -0
  19. package/dist/ToolResult-CL4hK_9z.d.mts +157 -0
  20. package/dist/ToolResult-CL4hK_9z.d.mts.map +1 -0
  21. package/dist/Toolkit-Dr9l_hNy.d.mts +196 -0
  22. package/dist/Toolkit-Dr9l_hNy.d.mts.map +1 -0
  23. package/dist/browser/Browser.d.mts +190 -0
  24. package/dist/browser/Browser.d.mts.map +1 -0
  25. package/dist/browser/Browser.mjs +34 -0
  26. package/dist/browser/Browser.mjs.map +1 -0
  27. package/dist/browser/BrowserError.d.mts +141 -0
  28. package/dist/browser/BrowserError.d.mts.map +1 -0
  29. package/dist/browser/BrowserError.mjs +89 -0
  30. package/dist/browser/BrowserError.mjs.map +1 -0
  31. package/dist/browser/BrowserTool.d.mts +58 -0
  32. package/dist/browser/BrowserTool.d.mts.map +1 -0
  33. package/dist/browser/BrowserTool.mjs +86 -0
  34. package/dist/browser/BrowserTool.mjs.map +1 -0
  35. package/dist/{dist-DV5ISja1.mjs → dist-B04dlFHg.mjs} +3 -3
  36. package/dist/{dist-DV5ISja1.mjs.map → dist-B04dlFHg.mjs.map} +1 -1
  37. package/dist/dist-DWgRbDN3.mjs +140 -0
  38. package/dist/dist-DWgRbDN3.mjs.map +1 -0
  39. package/dist/domain/AiError.d.mts +2 -2
  40. package/dist/domain/AiError.mjs +22 -3
  41. package/dist/domain/AiError.mjs.map +1 -1
  42. package/dist/domain/Audio.d.mts +1 -1
  43. package/dist/domain/Image.d.mts +1 -1
  44. package/dist/domain/Items.d.mts +1 -1
  45. package/dist/domain/Music.d.mts +1 -1
  46. package/dist/domain/Turn.d.mts +4 -4
  47. package/dist/domain/Turn.test.mjs +1 -1
  48. package/dist/embedding-model/Embedding.d.mts +1 -1
  49. package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
  50. package/dist/embedding-model/EmbeddingModel.test.mjs +1 -1
  51. package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -1
  52. package/dist/index.d.mts +19 -13
  53. package/dist/index.mjs +15 -9
  54. package/dist/language-model/LanguageModel.d.mts +10 -5
  55. package/dist/language-model/LanguageModel.d.mts.map +1 -1
  56. package/dist/language-model/LanguageModel.mjs.map +1 -1
  57. package/dist/language-model/LanguageModel.test.mjs +1 -1
  58. package/dist/loop/Loop.d.mts +2 -141
  59. package/dist/loop/Loop.mjs +16 -20
  60. package/dist/loop/Loop.mjs.map +1 -1
  61. package/dist/loop/Loop.test.mjs +18 -1
  62. package/dist/loop/Loop.test.mjs.map +1 -1
  63. package/dist/{magic-string.es-BgIV5Mu3.mjs → magic-string.es-Dk-MMA0F.mjs} +1 -1
  64. package/dist/{magic-string.es-BgIV5Mu3.mjs.map → magic-string.es-Dk-MMA0F.mjs.map} +1 -1
  65. package/dist/music-generator/MusicGenerator.d.mts +2 -2
  66. package/dist/music-generator/MusicGenerator.test.mjs +1 -1
  67. package/dist/observability/Metrics.d.mts +116 -28
  68. package/dist/observability/Metrics.d.mts.map +1 -1
  69. package/dist/observability/Metrics.mjs +250 -38
  70. package/dist/observability/Metrics.mjs.map +1 -1
  71. package/dist/observability/Metrics.test.d.mts +1 -0
  72. package/dist/observability/Metrics.test.mjs +187 -0
  73. package/dist/observability/Metrics.test.mjs.map +1 -0
  74. package/dist/observability/Telemetry.d.mts +37 -0
  75. package/dist/observability/Telemetry.d.mts.map +1 -0
  76. package/dist/observability/Telemetry.mjs +57 -0
  77. package/dist/observability/Telemetry.mjs.map +1 -0
  78. package/dist/observability/Telemetry.test.d.mts +1 -0
  79. package/dist/observability/Telemetry.test.mjs +92 -0
  80. package/dist/observability/Telemetry.test.mjs.map +1 -0
  81. package/dist/retry/Retry.d.mts +2 -2
  82. package/dist/retry/Retry.test.mjs +1 -1
  83. package/dist/sandbox/Sandbox.d.mts +1 -1
  84. package/dist/sandbox/SandboxError.d.mts +1 -1
  85. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +2 -2
  86. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +1 -1
  87. package/dist/streaming/JSONL.d.mts +1 -1
  88. package/dist/streaming/JSONL.test.mjs +1 -1
  89. package/dist/streaming/SSE.test.mjs +1 -1
  90. package/dist/structured-format/StructuredFormat.d.mts +1 -1
  91. package/dist/structured-format/StructuredFormat.mjs.map +1 -1
  92. package/dist/structured-format/StructuredFormat.test.mjs +1 -1
  93. package/dist/testing/MockMusicGenerator.d.mts +1 -1
  94. package/dist/testing/MockProvider.d.mts +1 -1
  95. package/dist/testing/MockSpeechSynthesizer.d.mts +1 -1
  96. package/dist/tool/Approval.d.mts +19 -7
  97. package/dist/tool/Approval.d.mts.map +1 -1
  98. package/dist/tool/Approval.mjs +18 -11
  99. package/dist/tool/Approval.mjs.map +1 -1
  100. package/dist/tool/Approval.test.mjs +80 -40
  101. package/dist/tool/Approval.test.mjs.map +1 -1
  102. package/dist/tool/HistoryCheck.d.mts +2 -2
  103. package/dist/tool/Tool.d.mts +2 -2
  104. package/dist/tool/Tool.mjs +190 -36
  105. package/dist/tool/Tool.mjs.map +1 -1
  106. package/dist/tool/Tool.test.mjs +41 -3
  107. package/dist/tool/Tool.test.mjs.map +1 -1
  108. package/dist/tool/ToolEvent.d.mts +2 -151
  109. package/dist/tool/ToolEvent.mjs +5 -0
  110. package/dist/tool/ToolEvent.mjs.map +1 -1
  111. package/dist/tool/ToolResult.d.mts +2 -138
  112. package/dist/tool/ToolResult.mjs +32 -8
  113. package/dist/tool/ToolResult.mjs.map +1 -1
  114. package/dist/tool/Toolkit.d.mts +2 -73
  115. package/dist/tool/Toolkit.mjs +153 -35
  116. package/dist/tool/Toolkit.mjs.map +1 -1
  117. package/dist/tool/Toolkit.test.mjs +271 -11
  118. package/dist/tool/Toolkit.test.mjs.map +1 -1
  119. package/dist/transcriber/Transcriber.d.mts +2 -2
  120. package/dist/transcriber/Transcriber.test.mjs +1 -1
  121. package/dist/web-read/WebRead.d.mts +44 -0
  122. package/dist/web-read/WebRead.d.mts.map +1 -0
  123. package/dist/web-read/WebRead.mjs +18 -0
  124. package/dist/web-read/WebRead.mjs.map +1 -0
  125. package/dist/web-read/WebReadTool.d.mts +57 -0
  126. package/dist/web-read/WebReadTool.d.mts.map +1 -0
  127. package/dist/web-read/WebReadTool.mjs +59 -0
  128. package/dist/web-read/WebReadTool.mjs.map +1 -0
  129. package/dist/web-search/WebSearch.d.mts +1 -1
  130. package/dist/web-search/WebSearchTool.d.mts +12 -6
  131. package/dist/web-search/WebSearchTool.d.mts.map +1 -1
  132. package/dist/web-search/WebSearchTool.mjs +10 -5
  133. package/dist/web-search/WebSearchTool.mjs.map +1 -1
  134. package/package.json +21 -1
  135. package/src/browser/Browser.ts +268 -0
  136. package/src/browser/BrowserError.ts +145 -0
  137. package/src/browser/BrowserTool.ts +161 -0
  138. package/src/domain/AiError.ts +27 -1
  139. package/src/embedding-model/EmbeddingModel.test.ts +4 -0
  140. package/src/index.ts +6 -0
  141. package/src/language-model/LanguageModel.ts +7 -2
  142. package/src/loop/Loop.test.ts +22 -0
  143. package/src/loop/Loop.ts +27 -4
  144. package/src/observability/Metrics.test.ts +266 -0
  145. package/src/observability/Metrics.ts +505 -65
  146. package/src/observability/Telemetry.test.ts +123 -0
  147. package/src/observability/Telemetry.ts +96 -0
  148. package/src/structured-format/StructuredFormat.ts +4 -0
  149. package/src/tool/Approval.test.ts +77 -26
  150. package/src/tool/Approval.ts +72 -27
  151. package/src/tool/Tool.test.ts +55 -2
  152. package/src/tool/Tool.ts +335 -70
  153. package/src/tool/ToolEvent.ts +5 -0
  154. package/src/tool/ToolResult.ts +36 -9
  155. package/src/tool/Toolkit.test.ts +339 -9
  156. package/src/tool/Toolkit.ts +470 -120
  157. package/src/web-read/WebRead.ts +70 -0
  158. package/src/web-read/WebReadTool.ts +96 -0
  159. package/src/web-search/WebSearchTool.ts +11 -5
  160. package/dist/Tool-BKiieFXk.d.mts +0 -102
  161. package/dist/Tool-BKiieFXk.d.mts.map +0 -1
  162. package/dist/loop/Loop.d.mts.map +0 -1
  163. package/dist/tool/ToolEvent.d.mts.map +0 -1
  164. package/dist/tool/ToolResult.d.mts.map +0 -1
  165. package/dist/tool/Toolkit.d.mts.map +0 -1
package/src/tool/Tool.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
2
- import { Effect, Schema, Stream } 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",
@@ -13,6 +14,59 @@ export class ToolError extends Schema.TaggedErrorClass<ToolError>("@betalyra/eff
13
14
  },
14
15
  ) {}
15
16
 
17
+ /**
18
+ * One Standard Schema validation issue, carried on `ToolValidationError`.
19
+ * Mirrors `StandardSchemaV1.Issue`; `path` is left as `unknown` elements
20
+ * because Standard Schema path segments may be symbols or `{ key }` objects.
21
+ */
22
+ const ToolValidationIssue = Schema.Struct({
23
+ message: Schema.String,
24
+ path: Schema.optional(Schema.Array(Schema.Unknown)),
25
+ })
26
+
27
+ /**
28
+ * The model's `arguments` for a call failed the tool's `inputSchema`. Distinct
29
+ * from `ToolError` (malformed JSON, runtime crash) so callers can tell a
30
+ * contract violation apart from a parse or execution failure, and so the
31
+ * structured `issues` survive instead of being flattened into a message string.
32
+ */
33
+ export class ToolValidationError extends Schema.TaggedErrorClass<ToolValidationError>(
34
+ "@betalyra/effect-uai/ToolValidationError",
35
+ )("ToolValidationError", {
36
+ call_id: Schema.String,
37
+ tool: Schema.String,
38
+ issues: Schema.Array(ToolValidationIssue),
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
+
16
70
  /**
17
71
  * Schemas accepted on `Tool.inputSchema`. Must implement both Standard
18
72
  * Schema validation and JSON Schema conversion (for rendering tool
@@ -33,8 +87,7 @@ export type ToolInputSchema<Input = unknown> = StandardSchemaV1<unknown, Input>
33
87
  export const fromEffectSchema = <S extends Schema.Codec<any, any, never, any>>(
34
88
  schema: S,
35
89
  ): S & ToolInputSchema<S["Type"]> =>
36
- Schema.toStandardJSONSchemaV1(Schema.toStandardSchemaV1(schema)) as unknown as S &
37
- ToolInputSchema<S["Type"]>
90
+ Schema.toStandardJSONSchemaV1(Schema.toStandardSchemaV1(schema))
38
91
 
39
92
  /**
40
93
  * Use any schema library that implements both Standard Schema (validation)
@@ -56,11 +109,56 @@ export const fromStandardSchema = <S extends StandardSchemaV1 & StandardJSONSche
56
109
  ): S & ToolInputSchema<StandardSchemaV1.InferOutput<S>> =>
57
110
  schema as S & ToolInputSchema<StandardSchemaV1.InferOutput<S>>
58
111
 
59
- export type Tool<Name extends string, Input, Output, R = never> = {
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
+
131
+ /**
132
+ * Emit one intermediate `Event` from inside a tool's `run`. Backed by the
133
+ * executor's per-call queue, so it returns an `Effect` (backpressure) and
134
+ * composes with `Stream.runForEach(emit)`.
135
+ */
136
+ export type Emit<Event> = (event: Event) => Effect.Effect<void>
137
+
138
+ /**
139
+ * A model-visible tool with a local `Effect` executor. `run` computes the
140
+ * model-facing `Output`; emitting progress is a side channel via `emit`. A
141
+ * plain tool never calls `emit` (its `Event` is irrelevant); a streaming tool
142
+ * emits intermediate `Event`s that flow to the consumer as
143
+ * `ToolEvent.Progress` in real time.
144
+ *
145
+ * This is one of four tool *kinds*, discriminated by `_tag`. The other three
146
+ * (`ProviderTool`, `SignalTool`, `InteractionTool`) are model-visible but have
147
+ * no local executor — see their constructors below. `Tool` keeps its name (and
148
+ * the `Tool.make` constructor) because the local kind is by far the common one.
149
+ */
150
+ export type Tool<Name extends string, Input, Event, Output, E = unknown, R = never> = {
151
+ readonly _tag: "LocalTool"
60
152
  readonly name: Name
61
153
  readonly description: string
62
154
  readonly inputSchema: ToolInputSchema<Input>
63
- readonly run: (input: Input) => Effect.Effect<Output, unknown, R>
155
+ readonly run: (input: Input, emit: Emit<Event>) => Effect.Effect<Output, E, R>
156
+ /**
157
+ * Bound on this tool's emit queue. Unbounded when omitted. Per-tool because
158
+ * backpressure depends on how a given tool emits, unlike `concurrency`
159
+ * (cross-tool, on `Toolkit.run`'s options).
160
+ */
161
+ readonly emitBufferSize?: number
64
162
  /**
65
163
  * Whether the provider should render this tool with its strict-mode
66
164
  * flag (OpenAI's `strict: true`, etc). Default: true. The framework
@@ -70,6 +168,70 @@ export type Tool<Name extends string, Input, Output, R = never> = {
70
168
  readonly strict?: boolean
71
169
  }
72
170
 
171
+ /**
172
+ * A provider-hosted tool: visible to the model and executed by the *provider*
173
+ * (native web search, code execution, RAG grounding), never by this process.
174
+ * It has no local `run`; `provider`/`config` tell the provider adapter how to
175
+ * render it. Passing it to `Toolkit.run` yields a `non_local_tool` result.
176
+ */
177
+ export type ProviderTool<Name extends string, Input, Provider extends string, Config> = {
178
+ readonly _tag: "ProviderTool"
179
+ readonly name: Name
180
+ readonly description: string
181
+ readonly inputSchema: ToolInputSchema<Input>
182
+ readonly provider: Provider
183
+ readonly config: Config
184
+ readonly strict?: boolean
185
+ }
186
+
187
+ /**
188
+ * A typed request for the agent loop to change control flow (escalate, pause,
189
+ * schedule, hand off). Model-visible and decodable (`decodeArgs`) but never
190
+ * locally executed — the loop intercepts the call in `onTurnComplete` and acts
191
+ * on it, so there is no fake `run`. `Toolkit.run` reports it as `non_local_tool`.
192
+ */
193
+ export type SignalTool<Name extends string, Input> = {
194
+ readonly _tag: "SignalTool"
195
+ readonly name: Name
196
+ readonly description: string
197
+ readonly inputSchema: ToolInputSchema<Input>
198
+ }
199
+
200
+ /**
201
+ * A typed request for an external actor (user, channel, frontend) to respond
202
+ * before the loop resumes. Like `SignalTool` it is decode-only, but its
203
+ * lifecycle differs: the loop usually stops/pauses and resumes later by
204
+ * appending a `function_call_output` once the actor replies.
205
+ */
206
+ export type InteractionTool<Name extends string, Input> = {
207
+ readonly _tag: "InteractionTool"
208
+ readonly name: Name
209
+ readonly description: string
210
+ readonly inputSchema: ToolInputSchema<Input>
211
+ }
212
+
213
+ /** A local (executable) tool of any shape. Readability alias. */
214
+ export type AnyLocalTool<E = any, R = any> = Tool<string, any, any, any, E, R>
215
+
216
+ /** Any of the four model-visible tool kinds. */
217
+ export type AnyTool<E = any, R = any> =
218
+ | Tool<string, any, any, any, E, R>
219
+ | ProviderTool<string, any, string, any>
220
+ | SignalTool<string, any>
221
+ | InteractionTool<string, any>
222
+
223
+ /** A model-visible tool that carries a decodable input schema (every kind). */
224
+ export type DecodableTool<Input> = {
225
+ readonly name: string
226
+ readonly inputSchema: ToolInputSchema<Input>
227
+ }
228
+
229
+ /** Extract the `R` of a local tool; non-local kinds contribute `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
234
+
73
235
  /**
74
236
  * Provider-agnostic tool descriptor. Each provider maps `inputSchema`
75
237
  * to its own wire field (OpenAI → `parameters`, Anthropic →
@@ -82,87 +244,190 @@ export type ToolDescriptor = {
82
244
  readonly strict?: boolean
83
245
  }
84
246
 
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
- }
247
+ /** A model-visible tool with a local `Effect` executor. */
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 })
107
251
 
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 })
252
+ /**
253
+ * A provider-hosted tool (native web search, code execution, RAG grounding).
254
+ * No local executor — `provider`/`config` drive the provider adapter's
255
+ * rendering. The model sees it; `Toolkit.run` reports it as `non_local_tool`.
256
+ */
257
+ export const provider = <Name extends string, Input, Provider extends string, Config>(
258
+ spec: Omit<ProviderTool<Name, Input, Provider, Config>, "_tag">,
259
+ ): ProviderTool<Name, Input, Provider, Config> => ({ _tag: "ProviderTool", ...spec })
111
260
 
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>
261
+ /**
262
+ * A typed control-flow signal to the agent loop (escalate, pause, schedule,
263
+ * hand off). Decode-only: the loop interprets the call rather than executing a
264
+ * handler, so there is no `run`.
265
+ */
266
+ export const signal = <Name extends string, Input>(
267
+ spec: Omit<SignalTool<Name, Input>, "_tag">,
268
+ ): SignalTool<Name, Input> => ({ _tag: "SignalTool", ...spec })
115
269
 
116
- export const isStreamingTool = <R>(t: AnyTool<R>): t is AnyStreamingTool<R> =>
117
- "_kind" in t && t._kind === "streaming"
270
+ /**
271
+ * A typed request for an external actor to respond before the loop resumes
272
+ * (frontend, Telegram, CLI prompt). Decode-only; the loop stops/pauses and
273
+ * resumes by appending the actor's `function_call_output`.
274
+ */
275
+ export const interaction = <Name extends string, Input>(
276
+ spec: Omit<InteractionTool<Name, Input>, "_tag">,
277
+ ): InteractionTool<Name, Input> => ({ _tag: "InteractionTool", ...spec })
118
278
 
119
279
  /**
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.
280
+ * Return a copy of a tool under a new `name`. Useful for resolving a name
281
+ * clash before combining tools into one `Toolkit`. Literal-preserving, so the
282
+ * renamed tool keys the toolkit record under the new name statically.
123
283
  */
124
- export const toDescriptors = <R>(tools: ReadonlyArray<AnyTool<R>>): ReadonlyArray<ToolDescriptor> =>
284
+ export const withName = <N extends string, T extends AnyTool>(
285
+ tool: T,
286
+ name: N,
287
+ ): Omit<T, "name"> & { readonly name: N } => ({ ...tool, name })
288
+
289
+ /**
290
+ * Replace a local tool's `run`, keeping its model-facing definition (name,
291
+ * description, schema, `_tag`) identical. The new `run`'s `input` is typed from
292
+ * the original tool, so override/mock need no annotation:
293
+ *
294
+ * const safe = { ...toolkit, send_email: Tool.withRun(toolkit.send_email,
295
+ * ({ to }) => Effect.succeed({ status: "dry-run", to })) }
296
+ *
297
+ * Descriptors stay identical to production, so a mocked toolkit can't drift
298
+ * from the contract the model sees.
299
+ */
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 })
314
+
315
+ /** Render tools to provider-agnostic descriptors. Every kind is model-visible. */
316
+ export const toDescriptors = (tools: ReadonlyArray<AnyTool>): ReadonlyArray<ToolDescriptor> =>
125
317
  tools.map((tool) => {
126
318
  const inputSchema = tool.inputSchema["~standard"].jsonSchema.input({
127
319
  target: "draft-2020-12",
128
320
  })
129
- return tool.strict !== undefined
130
- ? { name: tool.name, description: tool.description, inputSchema, strict: tool.strict }
321
+ // `strict` only exists on local/provider kinds; signals/interactions omit it.
322
+ const strict = "strict" in tool ? tool.strict : undefined
323
+ return strict !== undefined
324
+ ? { name: tool.name, description: tool.description, inputSchema, strict }
131
325
  : { name: tool.name, description: tool.description, inputSchema }
132
326
  })
133
327
 
134
- const toToolError = (call: ToolCall, toolName: string, message: string) => (cause: unknown) =>
135
- new ToolError({ call_id: call.call_id, tool: toolName, message, cause })
328
+ /**
329
+ * Render a `Toolkit` (a name-indexed record of tools) to descriptors, treating
330
+ * an absent toolkit as no tools. The normalization point the `LanguageModel`
331
+ * boundary uses so a request can carry the toolkit itself instead of a
332
+ * pre-rendered descriptor array. Lives here (not on `Toolkit`) to keep providers
333
+ * free of a dependency on the toolkit module — it only needs the tool values.
334
+ */
335
+ export const descriptorsOf = (
336
+ toolkit?: Readonly<Record<string, AnyTool>>,
337
+ ): ReadonlyArray<ToolDescriptor> =>
338
+ toolkit === undefined ? [] : toDescriptors(Object.values(toolkit))
339
+
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
+ }
385
+
386
+ /**
387
+ * Decode and validate a function_call's JSON `arguments` against the tool's
388
+ * own `inputSchema`, yielding the typed input. The decode-only half of
389
+ * `execute` - reach for it when you intercept a call to translate it into a
390
+ * control-flow decision rather than running the tool's `run`.
391
+ *
392
+ * Malformed JSON fails with `ToolError`; a parsed body that violates
393
+ * `inputSchema` fails with `ToolValidationError` carrying the issues.
394
+ */
395
+ export const decodeArgs = <Input>(
396
+ tool: DecodableTool<Input>,
397
+ call: ToolCall,
398
+ ): Effect.Effect<Input, ToolError | ToolValidationError> =>
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
+ )
136
419
 
137
420
  /**
138
421
  * Decode and validate the JSON arguments of a function_call against the
139
422
  * tool's input schema, run the tool, and serialize the output into a
140
- * function_call_output item.
423
+ * function_call_output item. Single-shot: intermediate events emitted by
424
+ * `run` are discarded (use `Toolkit.run` to stream them).
141
425
  */
142
- export const execute = <Name extends string, Input, Output, R>(
143
- tool: Tool<Name, Input, Output, R>,
426
+ export const execute = <Name extends string, Input, Event, Output, E, R>(
427
+ tool: Tool<Name, Input, Event, Output, E, R>,
144
428
  call: ToolCall,
145
- ): Effect.Effect<ToolCallOutput, ToolError, R> =>
146
- Effect.gen(function* () {
147
- const parsed = yield* Effect.try({
148
- try: () => JSON.parse(call.arguments) as unknown,
149
- catch: toToolError(call, tool.name, "Failed to parse JSON arguments"),
150
- })
151
-
152
- const result = yield* Effect.promise(() =>
153
- Promise.resolve(tool.inputSchema["~standard"].validate(parsed)),
154
- )
155
- if (result.issues !== undefined) {
156
- return yield* new ToolError({
157
- call_id: call.call_id,
158
- tool: tool.name,
159
- message: "Tool input failed schema validation",
160
- cause: result.issues,
161
- })
162
- }
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))
168
- })
429
+ ): Effect.Effect<ToolCallOutput, E | ToolError | ToolValidationError, R> =>
430
+ decodeArgs(tool, call).pipe(
431
+ Effect.flatMap((input) => tool.run(input, () => Effect.void)),
432
+ Effect.map((output) => toolCallOutput(call.call_id, serializeValue(output))),
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"
@@ -66,22 +66,49 @@ 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
  // ---------------------------------------------------------------------------
76
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
+
77
110
  export const toToolCallOutput = (r: ToolResult): ToolCallOutput =>
78
111
  ToolResult.$match(r, {
79
- Ok: (v) => toolCallOutput(v.call_id, JSON.stringify(v.value)),
80
- Failure: (f) =>
81
- toolCallOutput(
82
- f.call_id,
83
- JSON.stringify(
84
- f.reason !== undefined ? { kind: f.kind, reason: f.reason } : { kind: f.kind },
85
- ),
86
- ),
112
+ Ok: (v) => toolCallOutput(v.call_id, serializeValue(v.value)),
113
+ Failure: (f) => toolCallOutput(f.call_id, failureBody(f.kind, f.reason)),
87
114
  })