@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.
- package/dist/{AiError-CrdR1EQi.d.mts → AiError-Bf1pq4BB.d.mts} +8 -3
- package/dist/{AiError-CrdR1EQi.d.mts.map → AiError-Bf1pq4BB.d.mts.map} +1 -1
- package/dist/{Audio-DDSKCkSe.d.mts → Audio-CR7CSljJ.d.mts} +2 -2
- package/dist/{Audio-DDSKCkSe.d.mts.map → Audio-CR7CSljJ.d.mts.map} +1 -1
- package/dist/{Image-C_Zjpwhc.d.mts → Image-BZmKfIdq.d.mts} +2 -2
- package/dist/{Image-C_Zjpwhc.d.mts.map → Image-BZmKfIdq.d.mts.map} +1 -1
- package/dist/{Items-CgKD6Npw.d.mts → Items-BnIduEV2.d.mts} +9 -9
- package/dist/{Items-CgKD6Npw.d.mts.map → Items-BnIduEV2.d.mts.map} +1 -1
- package/dist/{Loop-B5_cqumi.d.mts → Loop-D8nWpowc.d.mts} +2 -2
- package/dist/{Loop-B5_cqumi.d.mts.map → Loop-D8nWpowc.d.mts.map} +1 -1
- package/dist/{Media-DOLyaygI.d.mts → Media-D_CpcM1Z.d.mts} +1 -1
- package/dist/{Media-DOLyaygI.d.mts.map → Media-D_CpcM1Z.d.mts.map} +1 -1
- package/dist/{StructuredFormat-CMJFD3sn.d.mts → StructuredFormat-DHaUJBdd.d.mts} +1 -1
- package/dist/{StructuredFormat-CMJFD3sn.d.mts.map → StructuredFormat-DHaUJBdd.d.mts.map} +1 -1
- package/dist/{Tool-0XUIT2A2.d.mts → Tool-2lC_XMth.d.mts} +64 -12
- package/dist/Tool-2lC_XMth.d.mts.map +1 -0
- package/dist/{ToolEvent-DYrAUJGs.d.mts → ToolEvent-cWZ2BnbS.d.mts} +2 -2
- package/dist/ToolEvent-cWZ2BnbS.d.mts.map +1 -0
- package/dist/{ToolResult-BZd-cEWW.d.mts → ToolResult-CL4hK_9z.d.mts} +10 -4
- package/dist/ToolResult-CL4hK_9z.d.mts.map +1 -0
- package/dist/{Toolkit-BKC8b8Xo.d.mts → Toolkit-Dr9l_hNy.d.mts} +64 -23
- package/dist/Toolkit-Dr9l_hNy.d.mts.map +1 -0
- package/dist/browser/Browser.d.mts +190 -0
- package/dist/browser/Browser.d.mts.map +1 -0
- package/dist/browser/Browser.mjs +34 -0
- package/dist/browser/Browser.mjs.map +1 -0
- package/dist/browser/BrowserError.d.mts +141 -0
- package/dist/browser/BrowserError.d.mts.map +1 -0
- package/dist/browser/BrowserError.mjs +89 -0
- package/dist/browser/BrowserError.mjs.map +1 -0
- package/dist/browser/BrowserTool.d.mts +58 -0
- package/dist/browser/BrowserTool.d.mts.map +1 -0
- package/dist/browser/BrowserTool.mjs +86 -0
- package/dist/browser/BrowserTool.mjs.map +1 -0
- package/dist/domain/AiError.d.mts +2 -2
- package/dist/domain/AiError.mjs +22 -3
- package/dist/domain/AiError.mjs.map +1 -1
- package/dist/domain/Audio.d.mts +1 -1
- package/dist/domain/Image.d.mts +1 -1
- package/dist/domain/Items.d.mts +1 -1
- package/dist/domain/Media.d.mts +1 -1
- package/dist/domain/Music.d.mts +1 -1
- package/dist/domain/Turn.d.mts +3 -3
- package/dist/embedding-model/Embedding.d.mts +1 -1
- package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
- package/dist/index.d.mts +17 -12
- package/dist/index.mjs +14 -9
- package/dist/language-model/LanguageModel.d.mts +4 -4
- package/dist/loop/Loop.d.mts +1 -1
- package/dist/music-generator/MusicGenerator.d.mts +2 -2
- package/dist/observability/Metrics.d.mts +1 -1
- package/dist/retry/Retry.d.mts +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +2 -2
- package/dist/structured-format/StructuredFormat.d.mts +1 -1
- package/dist/testing/MockMusicGenerator.d.mts +1 -1
- package/dist/testing/MockProvider.d.mts +1 -1
- package/dist/testing/MockSpeechSynthesizer.d.mts +1 -1
- package/dist/tool/Approval.d.mts +19 -7
- package/dist/tool/Approval.d.mts.map +1 -1
- package/dist/tool/Approval.mjs +18 -11
- package/dist/tool/Approval.mjs.map +1 -1
- package/dist/tool/Approval.test.mjs +46 -7
- package/dist/tool/Approval.test.mjs.map +1 -1
- package/dist/tool/HistoryCheck.d.mts +2 -2
- package/dist/tool/Tool.d.mts +2 -2
- package/dist/tool/Tool.mjs +86 -22
- package/dist/tool/Tool.mjs.map +1 -1
- package/dist/tool/ToolEvent.d.mts +1 -1
- package/dist/tool/ToolEvent.mjs +5 -0
- package/dist/tool/ToolEvent.mjs.map +1 -1
- package/dist/tool/ToolResult.d.mts +2 -2
- package/dist/tool/ToolResult.mjs +15 -6
- package/dist/tool/ToolResult.mjs.map +1 -1
- package/dist/tool/Toolkit.d.mts +2 -2
- package/dist/tool/Toolkit.mjs +62 -22
- package/dist/tool/Toolkit.mjs.map +1 -1
- package/dist/tool/Toolkit.test.mjs +163 -9
- package/dist/tool/Toolkit.test.mjs.map +1 -1
- package/dist/transcriber/Transcriber.d.mts +2 -2
- package/dist/web-read/WebRead.d.mts +44 -0
- package/dist/web-read/WebRead.d.mts.map +1 -0
- package/dist/web-read/WebRead.mjs +18 -0
- package/dist/web-read/WebRead.mjs.map +1 -0
- package/dist/web-read/WebReadTool.d.mts +57 -0
- package/dist/web-read/WebReadTool.d.mts.map +1 -0
- package/dist/web-read/WebReadTool.mjs +59 -0
- package/dist/web-read/WebReadTool.mjs.map +1 -0
- package/dist/web-search/WebSearch.d.mts +1 -1
- package/dist/web-search/WebSearchTool.d.mts +12 -6
- package/dist/web-search/WebSearchTool.d.mts.map +1 -1
- package/dist/web-search/WebSearchTool.mjs +10 -5
- package/dist/web-search/WebSearchTool.mjs.map +1 -1
- package/package.json +21 -1
- package/src/browser/Browser.ts +268 -0
- package/src/browser/BrowserError.ts +145 -0
- package/src/browser/BrowserTool.ts +161 -0
- package/src/domain/AiError.ts +27 -1
- package/src/index.ts +5 -0
- package/src/tool/Approval.test.ts +55 -5
- package/src/tool/Approval.ts +72 -27
- package/src/tool/Tool.ts +146 -47
- package/src/tool/ToolEvent.ts +5 -0
- package/src/tool/ToolResult.ts +14 -8
- package/src/tool/Toolkit.test.ts +171 -2
- package/src/tool/Toolkit.ts +173 -60
- package/src/web-read/WebRead.ts +70 -0
- package/src/web-read/WebReadTool.ts +96 -0
- package/src/web-search/WebSearchTool.ts +11 -5
- package/dist/Tool-0XUIT2A2.d.mts.map +0 -1
- package/dist/ToolEvent-DYrAUJGs.d.mts.map +0 -1
- package/dist/ToolResult-BZd-cEWW.d.mts.map +0 -1
- 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,
|
|
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 = <
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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
|
)
|
package/src/tool/ToolEvent.ts
CHANGED
|
@@ -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"
|
package/src/tool/ToolResult.ts
CHANGED
|
@@ -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,
|
|
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
|
})
|
package/src/tool/Toolkit.test.ts
CHANGED
|
@@ -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
|
+
})
|