@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.
- package/dist/{AiError-CAX_48RU.d.mts → AiError-CrdR1EQi.d.mts} +2 -2
- package/dist/{AiError-CAX_48RU.d.mts.map → AiError-CrdR1EQi.d.mts.map} +1 -1
- package/dist/{Audio-DPlTsNdz.d.mts → Audio-DDSKCkSe.d.mts} +2 -2
- package/dist/{Audio-DPlTsNdz.d.mts.map → Audio-DDSKCkSe.d.mts.map} +1 -1
- package/dist/{Image-HNmMpMTh.d.mts → Image-C_Zjpwhc.d.mts} +2 -2
- package/dist/{Image-HNmMpMTh.d.mts.map → Image-C_Zjpwhc.d.mts.map} +1 -1
- package/dist/{Items-D8UaXQx_.d.mts → Items-CgKD6Npw.d.mts} +5 -5
- package/dist/{Items-D8UaXQx_.d.mts.map → Items-CgKD6Npw.d.mts.map} +1 -1
- package/dist/Loop-B5_cqumi.d.mts +173 -0
- package/dist/Loop-B5_cqumi.d.mts.map +1 -0
- package/dist/{Media-D_CpcM1Z.d.mts → Media-DOLyaygI.d.mts} +1 -1
- package/dist/{Media-D_CpcM1Z.d.mts.map → Media-DOLyaygI.d.mts.map} +1 -1
- package/dist/{SandboxError-BvKypkJm.d.mts → SandboxError-CIrVDOMV.d.mts} +2 -2
- package/dist/{SandboxError-BvKypkJm.d.mts.map → SandboxError-CIrVDOMV.d.mts.map} +1 -1
- package/dist/{StructuredFormat-BbN4dosH.d.mts → StructuredFormat-CMJFD3sn.d.mts} +2 -2
- package/dist/{StructuredFormat-BbN4dosH.d.mts.map → StructuredFormat-CMJFD3sn.d.mts.map} +1 -1
- package/dist/Tool-0XUIT2A2.d.mts +232 -0
- package/dist/Tool-0XUIT2A2.d.mts.map +1 -0
- package/dist/ToolEvent-DYrAUJGs.d.mts +151 -0
- package/dist/ToolEvent-DYrAUJGs.d.mts.map +1 -0
- package/dist/ToolResult-BZd-cEWW.d.mts +151 -0
- package/dist/ToolResult-BZd-cEWW.d.mts.map +1 -0
- package/dist/Toolkit-BKC8b8Xo.d.mts +155 -0
- package/dist/Toolkit-BKC8b8Xo.d.mts.map +1 -0
- package/dist/{dist-DV5ISja1.mjs → dist-B04dlFHg.mjs} +3 -3
- package/dist/{dist-DV5ISja1.mjs.map → dist-B04dlFHg.mjs.map} +1 -1
- package/dist/dist-DWgRbDN3.mjs +140 -0
- package/dist/dist-DWgRbDN3.mjs.map +1 -0
- package/dist/domain/AiError.d.mts +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 +4 -4
- package/dist/domain/Turn.test.mjs +1 -1
- package/dist/embedding-model/Embedding.d.mts +1 -1
- package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
- package/dist/embedding-model/EmbeddingModel.test.mjs +1 -1
- package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -1
- package/dist/index.d.mts +16 -13
- package/dist/index.mjs +5 -2
- package/dist/language-model/LanguageModel.d.mts +10 -5
- package/dist/language-model/LanguageModel.d.mts.map +1 -1
- package/dist/language-model/LanguageModel.mjs.map +1 -1
- package/dist/language-model/LanguageModel.test.mjs +1 -1
- package/dist/loop/Loop.d.mts +2 -141
- package/dist/loop/Loop.mjs +16 -20
- package/dist/loop/Loop.mjs.map +1 -1
- package/dist/loop/Loop.test.mjs +18 -1
- package/dist/loop/Loop.test.mjs.map +1 -1
- package/dist/{magic-string.es-BgIV5Mu3.mjs → magic-string.es-Dk-MMA0F.mjs} +1 -1
- package/dist/{magic-string.es-BgIV5Mu3.mjs.map → magic-string.es-Dk-MMA0F.mjs.map} +1 -1
- package/dist/music-generator/MusicGenerator.d.mts +2 -2
- package/dist/music-generator/MusicGenerator.test.mjs +1 -1
- package/dist/observability/Metrics.d.mts +116 -28
- package/dist/observability/Metrics.d.mts.map +1 -1
- package/dist/observability/Metrics.mjs +250 -38
- package/dist/observability/Metrics.mjs.map +1 -1
- package/dist/observability/Metrics.test.d.mts +1 -0
- package/dist/observability/Metrics.test.mjs +187 -0
- package/dist/observability/Metrics.test.mjs.map +1 -0
- package/dist/observability/Telemetry.d.mts +37 -0
- package/dist/observability/Telemetry.d.mts.map +1 -0
- package/dist/observability/Telemetry.mjs +57 -0
- package/dist/observability/Telemetry.mjs.map +1 -0
- package/dist/observability/Telemetry.test.d.mts +1 -0
- package/dist/observability/Telemetry.test.mjs +92 -0
- package/dist/observability/Telemetry.test.mjs.map +1 -0
- package/dist/retry/Retry.d.mts +2 -2
- package/dist/retry/Retry.test.mjs +1 -1
- package/dist/sandbox/Sandbox.d.mts +1 -1
- package/dist/sandbox/SandboxError.d.mts +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +2 -2
- package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +1 -1
- package/dist/streaming/JSONL.d.mts +1 -1
- package/dist/streaming/JSONL.test.mjs +1 -1
- package/dist/streaming/SSE.test.mjs +1 -1
- package/dist/structured-format/StructuredFormat.d.mts +1 -1
- package/dist/structured-format/StructuredFormat.mjs.map +1 -1
- package/dist/structured-format/StructuredFormat.test.mjs +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 +3 -3
- package/dist/tool/Approval.test.mjs +35 -34
- 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 +112 -22
- package/dist/tool/Tool.mjs.map +1 -1
- package/dist/tool/Tool.test.mjs +41 -3
- package/dist/tool/Tool.test.mjs.map +1 -1
- package/dist/tool/ToolEvent.d.mts +2 -151
- package/dist/tool/ToolResult.d.mts +2 -138
- package/dist/tool/ToolResult.mjs +18 -3
- package/dist/tool/ToolResult.mjs.map +1 -1
- package/dist/tool/Toolkit.d.mts +2 -73
- package/dist/tool/Toolkit.mjs +106 -28
- package/dist/tool/Toolkit.mjs.map +1 -1
- package/dist/tool/Toolkit.test.mjs +113 -7
- package/dist/tool/Toolkit.test.mjs.map +1 -1
- package/dist/transcriber/Transcriber.d.mts +2 -2
- package/dist/transcriber/Transcriber.test.mjs +1 -1
- package/dist/web-search/WebSearch.d.mts +119 -0
- package/dist/web-search/WebSearch.d.mts.map +1 -0
- package/dist/web-search/WebSearch.mjs +42 -0
- package/dist/web-search/WebSearch.mjs.map +1 -0
- package/dist/web-search/WebSearchTool.d.mts +58 -0
- package/dist/web-search/WebSearchTool.d.mts.map +1 -0
- package/dist/web-search/WebSearchTool.mjs +69 -0
- package/dist/web-search/WebSearchTool.mjs.map +1 -0
- package/package.json +9 -1
- package/src/embedding-model/EmbeddingModel.test.ts +4 -0
- package/src/index.ts +3 -0
- package/src/language-model/LanguageModel.ts +7 -2
- package/src/loop/Loop.test.ts +22 -0
- package/src/loop/Loop.ts +27 -4
- package/src/observability/Metrics.test.ts +266 -0
- package/src/observability/Metrics.ts +505 -65
- package/src/observability/Telemetry.test.ts +123 -0
- package/src/observability/Telemetry.ts +96 -0
- package/src/structured-format/StructuredFormat.ts +4 -0
- package/src/tool/Approval.test.ts +22 -21
- package/src/tool/Tool.test.ts +55 -2
- package/src/tool/Tool.ts +221 -55
- package/src/tool/ToolResult.ts +22 -1
- package/src/tool/Toolkit.test.ts +168 -7
- package/src/tool/Toolkit.ts +336 -99
- package/src/web-search/WebSearch.ts +150 -0
- package/src/web-search/WebSearchTool.ts +106 -0
- package/dist/Tool-CiFXYjmR.d.mts +0 -102
- package/dist/Tool-CiFXYjmR.d.mts.map +0 -1
- package/dist/loop/Loop.d.mts.map +0 -1
- package/dist/tool/ToolEvent.d.mts.map +0 -1
- package/dist/tool/ToolResult.d.mts.map +0 -1
- package/dist/tool/Toolkit.d.mts.map +0 -1
package/src/tool/Toolkit.ts
CHANGED
|
@@ -1,35 +1,306 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Array as Arr,
|
|
3
|
+
Cause,
|
|
4
|
+
Data,
|
|
5
|
+
Effect,
|
|
6
|
+
Fiber,
|
|
7
|
+
Function,
|
|
8
|
+
Match,
|
|
9
|
+
Option,
|
|
10
|
+
Queue,
|
|
11
|
+
Record,
|
|
12
|
+
Ref,
|
|
13
|
+
Schema,
|
|
14
|
+
Stream,
|
|
15
|
+
} from "effect"
|
|
2
16
|
import * as Loop from "../loop/Loop.js"
|
|
3
|
-
import type {
|
|
17
|
+
import type { HistoryItem, ToolCall } from "../domain/Items.js"
|
|
4
18
|
import { appendToHistory, type Turn } from "../domain/Turn.js"
|
|
5
19
|
import {
|
|
20
|
+
type AnyLocalTool,
|
|
6
21
|
type AnyTool,
|
|
7
|
-
type
|
|
8
|
-
type
|
|
9
|
-
|
|
10
|
-
type
|
|
22
|
+
type Emit,
|
|
23
|
+
type InteractionTool,
|
|
24
|
+
type ProviderTool,
|
|
25
|
+
type SignalTool,
|
|
11
26
|
type Tool,
|
|
27
|
+
type ToolDescriptor,
|
|
28
|
+
type ToolR,
|
|
29
|
+
toDescriptors,
|
|
12
30
|
} from "./Tool.js"
|
|
13
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
ToolResult,
|
|
33
|
+
executionError,
|
|
34
|
+
failed,
|
|
35
|
+
nonLocalTool,
|
|
36
|
+
toToolCallOutput,
|
|
37
|
+
validationError,
|
|
38
|
+
} from "./ToolResult.js"
|
|
14
39
|
import { ToolEvent } from "./ToolEvent.js"
|
|
15
40
|
import { isOutput } from "./ToolEvent.js"
|
|
16
41
|
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Toolkit: a name-indexed record of tools — "what the model sees". The record
|
|
44
|
+
// gives O(1) dispatch and within-toolkit name-uniqueness for free; descriptors
|
|
45
|
+
// are derived, not stored. Build a static one with `make` (compile-time
|
|
46
|
+
// duplicate-name check), a dynamic one with `fromArray` (trusted single source,
|
|
47
|
+
// last-wins), and combine independent ones with `compose` (the application
|
|
48
|
+
// boundary where cross-source names can clash).
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
export type ToolMap = Record<string, AnyTool>
|
|
52
|
+
|
|
53
|
+
export type Toolkit<Tools extends ToolMap = ToolMap> = Tools
|
|
54
|
+
|
|
55
|
+
const indexByName = (tools: ReadonlyArray<AnyTool>): ToolMap =>
|
|
56
|
+
Object.fromEntries(tools.map((tool) => [tool.name, tool]))
|
|
57
|
+
|
|
17
58
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
59
|
+
* Index literal tools by their `name`. Duplicate names are a compile error
|
|
60
|
+
* (`UniqueTools`). Names are not otherwise checked: a malformed name is a
|
|
61
|
+
* developer typo in a literal that the provider rejects clearly on first use,
|
|
62
|
+
* and there is no single cross-provider name rule to enforce here anyway.
|
|
21
63
|
*/
|
|
22
|
-
|
|
23
|
-
Tools
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
64
|
+
const buildStatic = <Tools extends ReadonlyArray<AnyTool>>(
|
|
65
|
+
tools: Tools,
|
|
66
|
+
): Toolkit<{ [T in Tools[number] as T["name"]]: T }> =>
|
|
67
|
+
indexByName(tools) as Toolkit<{ [T in Tools[number] as T["name"]]: T }>
|
|
68
|
+
|
|
69
|
+
export const make = <const Tools extends ReadonlyArray<AnyTool>>(
|
|
70
|
+
...tools: UniqueTools<Tools>
|
|
71
|
+
): Toolkit<{ [T in Tools[number] as T["name"]]: T }> => buildStatic(tools as unknown as Tools)
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build a toolkit from a dynamically-sourced array (MCP server, plugin). Names
|
|
75
|
+
* are not validated (MCP legitimately uses dots / >64 chars — sanitize at the
|
|
76
|
+
* provider adapter); same-named tools resolve last-wins. Cross-source clashes
|
|
77
|
+
* are caught by `compose`.
|
|
78
|
+
*/
|
|
79
|
+
export const fromArray = (tools: ReadonlyArray<AnyTool>): Toolkit => indexByName(tools)
|
|
80
|
+
|
|
81
|
+
/** Render the provider-facing descriptors for a toolkit on demand. */
|
|
82
|
+
export const descriptors = (toolkit: Toolkit): ReadonlyArray<ToolDescriptor> =>
|
|
83
|
+
toDescriptors(Object.values(toolkit))
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Union of every tool's `R` requirements in a toolkit. Surfaced by `run` so the
|
|
87
|
+
* loop's stream type carries the services tools need through to `Effect.provide`.
|
|
88
|
+
*
|
|
89
|
+
* The `ToolMap extends T` guard yields `never` for the wide `Toolkit` type (a
|
|
90
|
+
* toolkit passed through an untyped boundary, where the requirements aren't
|
|
91
|
+
* statically known) instead of `any`, which would otherwise poison the
|
|
92
|
+
* resulting stream's `R`. Concrete toolkits from `make` keep their precise R.
|
|
93
|
+
*/
|
|
94
|
+
export type ToolkitR<T extends Toolkit> = ToolMap extends T ? never : ToolR<T[keyof T]>
|
|
28
95
|
|
|
29
96
|
// ---------------------------------------------------------------------------
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
97
|
+
// Uniqueness & composition. `make` rejects duplicate literal names at compile
|
|
98
|
+
// time; `compose` rejects cross-source collisions — at compile time when the
|
|
99
|
+
// names are statically known, and at runtime (`DuplicateToolName`) for dynamic
|
|
100
|
+
// sources whose keys are only `string`. No silent last-wins overwrite, no late
|
|
101
|
+
// provider 400.
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
// --- compile-time duplicate detection --------------------------------------
|
|
105
|
+
|
|
106
|
+
type DuplicateName<
|
|
107
|
+
Tools extends ReadonlyArray<AnyTool>,
|
|
108
|
+
Seen extends string = never,
|
|
109
|
+
> = Tools extends readonly [
|
|
110
|
+
infer Head extends AnyTool,
|
|
111
|
+
...infer Tail extends ReadonlyArray<AnyTool>,
|
|
112
|
+
]
|
|
113
|
+
? Head["name"] extends Seen
|
|
114
|
+
? Head["name"]
|
|
115
|
+
: DuplicateName<Tail, Seen | Head["name"]>
|
|
116
|
+
: never
|
|
117
|
+
|
|
118
|
+
type DuplicateNameError<Name extends string> = {
|
|
119
|
+
readonly __error: `Duplicate tool name: ${Name}`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type NoDuplicateNames<Tools extends ReadonlyArray<AnyTool>> = [
|
|
123
|
+
DuplicateName<Tools>,
|
|
124
|
+
] extends [never]
|
|
125
|
+
? unknown
|
|
126
|
+
: DuplicateNameError<DuplicateName<Tools>>
|
|
127
|
+
|
|
128
|
+
/** `Tools` constrained so a repeated literal name is a compile error in `make`. */
|
|
129
|
+
export type UniqueTools<Tools extends ReadonlyArray<AnyTool>> = Tools & NoDuplicateNames<Tools>
|
|
130
|
+
|
|
131
|
+
// Cross-toolkit literal-key collision, skipping wide (`string`-keyed, dynamic)
|
|
132
|
+
// toolkits so the compile-time check never false-positives on `fromArray`.
|
|
133
|
+
type ComposeDuplicateKey<
|
|
134
|
+
Kits extends ReadonlyArray<Toolkit>,
|
|
135
|
+
Seen extends string = never,
|
|
136
|
+
> = Kits extends readonly [infer Head extends Toolkit, ...infer Tail extends ReadonlyArray<Toolkit>]
|
|
137
|
+
? string extends keyof Head
|
|
138
|
+
? ComposeDuplicateKey<Tail, Seen>
|
|
139
|
+
: Extract<keyof Head & string, Seen> extends never
|
|
140
|
+
? ComposeDuplicateKey<Tail, Seen | (keyof Head & string)>
|
|
141
|
+
: Extract<keyof Head & string, Seen>
|
|
142
|
+
: never
|
|
143
|
+
|
|
144
|
+
type NoDuplicateComposedKeys<Kits extends ReadonlyArray<Toolkit>> = [
|
|
145
|
+
ComposeDuplicateKey<Kits>,
|
|
146
|
+
] extends [never]
|
|
147
|
+
? unknown
|
|
148
|
+
: DuplicateNameError<ComposeDuplicateKey<Kits>>
|
|
149
|
+
|
|
150
|
+
type UnionToIntersection<U> = (U extends unknown ? (x: U) => void : never) extends (
|
|
151
|
+
x: infer I,
|
|
152
|
+
) => void
|
|
153
|
+
? I
|
|
154
|
+
: never
|
|
155
|
+
|
|
156
|
+
type Composed<Kits extends ReadonlyArray<Toolkit>> =
|
|
157
|
+
UnionToIntersection<Kits[number]> extends infer M ? (M extends ToolMap ? M : ToolMap) : ToolMap
|
|
158
|
+
|
|
159
|
+
// --- uniqueness error ------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Two composed toolkits resolved to the same final name. Carries the colliding
|
|
163
|
+
* `sources` (the positions of the clashing toolkits) so the error says *which*
|
|
164
|
+
* inputs collided. Raised at runtime by `compose` for dynamic sources; static
|
|
165
|
+
* collisions are caught earlier at compile time.
|
|
166
|
+
*/
|
|
167
|
+
export class DuplicateToolName extends Data.TaggedError("DuplicateToolName")<{
|
|
168
|
+
readonly name: string
|
|
169
|
+
readonly sources: ReadonlyArray<string>
|
|
170
|
+
}> {}
|
|
171
|
+
|
|
172
|
+
// --- namespacing -----------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
type PrefixName<Prefix extends string, Name extends string> = `${Prefix}__${Name}`
|
|
175
|
+
|
|
176
|
+
type PrefixTool<Prefix extends string, T extends AnyTool> =
|
|
177
|
+
T extends Tool<infer N, infer I, infer Ev, infer O, infer R>
|
|
178
|
+
? Tool<PrefixName<Prefix, N>, I, Ev, O, R>
|
|
179
|
+
: T extends ProviderTool<infer N, infer I, infer P, infer C>
|
|
180
|
+
? ProviderTool<PrefixName<Prefix, N>, I, P, C>
|
|
181
|
+
: T extends SignalTool<infer N, infer I>
|
|
182
|
+
? SignalTool<PrefixName<Prefix, N>, I>
|
|
183
|
+
: T extends InteractionTool<infer N, infer I>
|
|
184
|
+
? InteractionTool<PrefixName<Prefix, N>, I>
|
|
185
|
+
: T
|
|
186
|
+
|
|
187
|
+
type Namespaced<Prefix extends string, T extends ToolMap> = {
|
|
188
|
+
readonly [K in keyof T & string as PrefixName<Prefix, K>]: T[K] extends AnyTool
|
|
189
|
+
? PrefixTool<Prefix, T[K]>
|
|
190
|
+
: never
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Prefix every tool name with `<namespace>__`, returning a new toolkit. Use it
|
|
195
|
+
* to keep generic names (`search`) from two sources distinct before `compose`.
|
|
196
|
+
* If two namespaces collide at the `__` separator, the duplicate final name
|
|
197
|
+
* surfaces from `compose` as a `DuplicateToolName`.
|
|
198
|
+
*/
|
|
199
|
+
export const namespace = <const Prefix extends string, const T extends ToolMap>(
|
|
200
|
+
prefix: Prefix,
|
|
201
|
+
toolkit: Toolkit<T>,
|
|
202
|
+
): Toolkit<Namespaced<Prefix, T>> => {
|
|
203
|
+
const renamed = Arr.map(Record.values(toolkit), (tool) => ({
|
|
204
|
+
...tool,
|
|
205
|
+
name: `${prefix}__${tool.name}`,
|
|
206
|
+
}))
|
|
207
|
+
return indexByName(renamed) as Toolkit<Namespaced<Prefix, T>>
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** `namespace(prefix, make(...tools))` in one step, for static namespaced tools. */
|
|
211
|
+
export const makeNamespaced = <
|
|
212
|
+
const Prefix extends string,
|
|
213
|
+
const Tools extends ReadonlyArray<AnyTool>,
|
|
214
|
+
>(
|
|
215
|
+
prefix: Prefix,
|
|
216
|
+
...tools: UniqueTools<Tools>
|
|
217
|
+
): Toolkit<Namespaced<Prefix, { [T in Tools[number] as T["name"]]: T }>> =>
|
|
218
|
+
namespace(prefix, buildStatic(tools as unknown as Tools))
|
|
219
|
+
|
|
220
|
+
// --- composition (the application boundary) --------------------------------
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Combine independently-built toolkits into one. This is where names from
|
|
224
|
+
* separate sources can collide, so it is the one effectful constructor: a
|
|
225
|
+
* duplicate final name fails with `DuplicateToolName` (naming the colliding
|
|
226
|
+
* input positions) instead of silently overwriting or 400-ing later at the
|
|
227
|
+
* provider. Statically-known collisions are additionally a compile error.
|
|
228
|
+
*/
|
|
229
|
+
export const compose = <const Kits extends ReadonlyArray<Toolkit>>(
|
|
230
|
+
...toolkits: Kits & NoDuplicateComposedKeys<Kits>
|
|
231
|
+
): Effect.Effect<Toolkit<Composed<Kits>>, DuplicateToolName> =>
|
|
232
|
+
Effect.gen(function* () {
|
|
233
|
+
const kits = toolkits as unknown as ReadonlyArray<Toolkit>
|
|
234
|
+
const labelled = Arr.flatMap(kits, (kit, i) =>
|
|
235
|
+
Arr.map(Record.keys(kit), (name) => ({ name, source: `toolkit-${i}` })),
|
|
236
|
+
)
|
|
237
|
+
const collision = Arr.findFirst(
|
|
238
|
+
Record.toEntries(Arr.groupBy(labelled, (entry) => entry.name)),
|
|
239
|
+
([, group]) => group.length > 1,
|
|
240
|
+
)
|
|
241
|
+
if (Option.isSome(collision)) {
|
|
242
|
+
const [name, group] = collision.value
|
|
243
|
+
return yield* new DuplicateToolName({ name, sources: Arr.map(group, (e) => e.source) })
|
|
244
|
+
}
|
|
245
|
+
return Arr.reduce(kits, {} as ToolMap, (acc, kit) => ({ ...acc, ...kit })) as Toolkit<
|
|
246
|
+
Composed<Kits>
|
|
247
|
+
>
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Middleware: a `Toolkit -> Toolkit` transform applied up front, then the
|
|
252
|
+
// wrapped toolkit is run. A middleware transforms one local tool's `run` given
|
|
253
|
+
// its name (logging, retry, auth, metrics); `wrap` lifts it over the whole
|
|
254
|
+
// toolkit, leaving provider/signal/interaction kinds untouched. The one reason
|
|
255
|
+
// this is a combinator rather than native `Record.map`: it tracks the
|
|
256
|
+
// middleware's added requirement `R2`, unioning it into `ToolkitR` instead of
|
|
257
|
+
// widening every tool's type.
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
type Handler<Input, Event, Output, R> = (
|
|
261
|
+
input: Input,
|
|
262
|
+
emit: Emit<Event>,
|
|
263
|
+
) => Effect.Effect<Output, unknown, R>
|
|
264
|
+
|
|
265
|
+
export type Middleware<R2 = never> = <Input, Event, Output, R>(
|
|
266
|
+
run: Handler<Input, Event, Output, R>,
|
|
267
|
+
name: string,
|
|
268
|
+
) => Handler<Input, Event, Output, R | R2>
|
|
269
|
+
|
|
270
|
+
type WrapTool<T extends AnyTool, R2> =
|
|
271
|
+
T extends Tool<infer N, infer I, infer Ev, infer O, infer R> ? Tool<N, I, Ev, O, R | R2> : T
|
|
272
|
+
|
|
273
|
+
type WrapToolkit<T extends ToolMap, R2> = {
|
|
274
|
+
readonly [K in keyof T]: T[K] extends AnyTool ? WrapTool<T[K], R2> : T[K]
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Wrap every local tool's `run` with a middleware, preserving each tool's
|
|
279
|
+
* `Input`/`Output` and unioning the middleware's requirement `R2` into the
|
|
280
|
+
* toolkit's `ToolkitR`. Non-local kinds pass through unchanged. Stack with
|
|
281
|
+
* `pipe`:
|
|
282
|
+
*
|
|
283
|
+
* const observed = pipe(toolkit, Toolkit.wrap(logging), Toolkit.wrap(authz))
|
|
284
|
+
* Toolkit.run(observed, calls) // R = ToolkitR<typeof toolkit> | <authz R>
|
|
285
|
+
*
|
|
286
|
+
* A middleware that `Effect.fail`s surfaces as a `ToolResult.Failure` from the
|
|
287
|
+
* executor like any run failure; use the `Approval` flow for a distinct
|
|
288
|
+
* "denied" verdict.
|
|
289
|
+
*/
|
|
290
|
+
export const wrap: {
|
|
291
|
+
<R2>(middleware: Middleware<R2>): <T extends Toolkit>(toolkit: T) => WrapToolkit<T, R2>
|
|
292
|
+
<T extends Toolkit, R2>(toolkit: T, middleware: Middleware<R2>): WrapToolkit<T, R2>
|
|
293
|
+
} = Function.dual(
|
|
294
|
+
2,
|
|
295
|
+
<T extends Toolkit, R2>(toolkit: T, middleware: Middleware<R2>): WrapToolkit<T, R2> =>
|
|
296
|
+
Record.map(toolkit, (tool) =>
|
|
297
|
+
tool._tag === "LocalTool" ? { ...tool, run: middleware(tool.run, tool.name) } : tool,
|
|
298
|
+
) as WrapToolkit<T, R2>,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// Tool executor. Streams `ToolEvent`s in real time. Policy stays outside this
|
|
303
|
+
// module: callers pass only the calls they have already decided should run.
|
|
33
304
|
// ---------------------------------------------------------------------------
|
|
34
305
|
|
|
35
306
|
export type ExecuteOptions = {
|
|
@@ -37,69 +308,40 @@ export type ExecuteOptions = {
|
|
|
37
308
|
}
|
|
38
309
|
|
|
39
310
|
/** Execute every provided call. Approval/rejection policy belongs upstream. */
|
|
40
|
-
export const run = <
|
|
41
|
-
|
|
311
|
+
export const run = <T extends Toolkit>(
|
|
312
|
+
toolkit: T,
|
|
42
313
|
calls: ReadonlyArray<ToolCall>,
|
|
43
314
|
options?: ExecuteOptions,
|
|
44
|
-
): Stream.Stream<ToolEvent, never,
|
|
315
|
+
): Stream.Stream<ToolEvent, never, ToolkitR<T>> =>
|
|
45
316
|
Stream.fromIterable(calls).pipe(
|
|
46
|
-
Stream.flatMap((call) => runOne(
|
|
317
|
+
Stream.flatMap((call) => runOne(toolkit, call), {
|
|
47
318
|
concurrency: options?.concurrency ?? "unbounded",
|
|
48
319
|
}),
|
|
49
|
-
)
|
|
320
|
+
) as Stream.Stream<ToolEvent, never, ToolkitR<T>>
|
|
50
321
|
|
|
51
322
|
const okResult = (call: ToolCall, tool: string, value: unknown): ToolResult =>
|
|
52
|
-
ToolResult.Ok({
|
|
53
|
-
call_id: call.call_id,
|
|
54
|
-
tool,
|
|
55
|
-
value,
|
|
56
|
-
})
|
|
323
|
+
ToolResult.Ok({ call_id: call.call_id, tool, value })
|
|
57
324
|
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
call: ToolCall,
|
|
61
|
-
): Stream.Stream<ToolEvent, never, R> => {
|
|
62
|
-
const tool = tools.find((t) => t.name === call.name)
|
|
63
|
-
if (tool === undefined) {
|
|
64
|
-
// Graceful: emit a synthetic Failure so OTHER calls in this turn
|
|
65
|
-
// still execute. LLMs hallucinate tool names; MCP tools come and go.
|
|
66
|
-
return Stream.succeed(
|
|
67
|
-
ToolEvent.Output({
|
|
68
|
-
result: failed(call, "unknown_tool", `No tool registered with name "${call.name}"`),
|
|
69
|
-
}),
|
|
70
|
-
)
|
|
71
|
-
}
|
|
72
|
-
if (isStreamingTool(tool)) return runStreaming(tool, call)
|
|
73
|
-
return runPlain(tool, call)
|
|
74
|
-
}
|
|
325
|
+
const outputEvent = (result: ToolResult): Stream.Stream<ToolEvent, never, any> =>
|
|
326
|
+
Stream.succeed(ToolEvent.Output({ result }))
|
|
75
327
|
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
Effect.mapError(() => "json_parse_error" as const),
|
|
83
|
-
)
|
|
84
|
-
const validated = yield* Effect.tryPromise({
|
|
85
|
-
try: () => Promise.resolve(tool.inputSchema["~standard"].validate(parsed)),
|
|
86
|
-
catch: () => "validation_threw" as const,
|
|
87
|
-
})
|
|
88
|
-
if (validated.issues !== undefined) {
|
|
89
|
-
return executionError(call, "Tool input failed schema validation")
|
|
90
|
-
}
|
|
91
|
-
const output = yield* tool.run(validated.value)
|
|
92
|
-
return okResult(call, tool.name, output)
|
|
93
|
-
}).pipe(
|
|
94
|
-
Effect.catchCause(() => Effect.succeed(executionError(call, "Tool execution failed"))),
|
|
95
|
-
Effect.map((result) => ToolEvent.Output({ result })),
|
|
328
|
+
const runOne = (toolkit: ToolMap, call: ToolCall): Stream.Stream<ToolEvent, never, any> =>
|
|
329
|
+
Match.value(toolkit[call.name]).pipe(
|
|
330
|
+
// No such tool. Graceful: emit a synthetic Failure so OTHER calls in this
|
|
331
|
+
// turn still execute. LLMs hallucinate tool names; MCP tools come and go.
|
|
332
|
+
Match.when(Match.undefined, () =>
|
|
333
|
+
outputEvent(failed(call, "unknown_tool", `No tool registered with name "${call.name}"`)),
|
|
96
334
|
),
|
|
335
|
+
Match.tag("LocalTool", (local) => runTool(local, call)),
|
|
336
|
+
// Provider/signal/interaction tools are model-visible but not locally
|
|
337
|
+
// executable. The loop is expected to intercept these (decode + act) before
|
|
338
|
+
// execution; reaching here means it didn't, so report it rather than crash.
|
|
339
|
+
Match.orElse(() => outputEvent(nonLocalTool(call))),
|
|
97
340
|
)
|
|
98
341
|
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
): Stream.Stream<ToolEvent, never, R> =>
|
|
342
|
+
const parseJsonUnknown = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
|
|
343
|
+
|
|
344
|
+
const runTool = (tool: AnyLocalTool, call: ToolCall): Stream.Stream<ToolEvent, never, any> =>
|
|
103
345
|
Stream.unwrap(
|
|
104
346
|
Effect.gen(function* () {
|
|
105
347
|
const parsed = yield* parseJsonUnknown(call.arguments).pipe(
|
|
@@ -110,45 +352,40 @@ const runStreaming = <R>(
|
|
|
110
352
|
catch: () => "validation_threw" as const,
|
|
111
353
|
})
|
|
112
354
|
if (validated.issues !== undefined) {
|
|
113
|
-
return Stream.succeed<ToolEvent>(
|
|
114
|
-
ToolEvent.Output({
|
|
115
|
-
result: executionError(call, "Tool input failed schema validation"),
|
|
116
|
-
}),
|
|
117
|
-
)
|
|
355
|
+
return Stream.succeed<ToolEvent>(ToolEvent.Output({ result: validationError(call) }))
|
|
118
356
|
}
|
|
119
357
|
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
),
|
|
358
|
+
// Per-call queue: `emit` offers events as `run` produces them; the
|
|
359
|
+
// progress stream drains them in real time; `Queue.end` (in the
|
|
360
|
+
// `ensuring`) flushes pending events then signals a clean end, which
|
|
361
|
+
// `Stream.fromQueue` treats as completion. `Queue.shutdown` would clear
|
|
362
|
+
// queued items and interrupt pending takes — wrong for graceful teardown.
|
|
363
|
+
const queue = yield* Queue.make<unknown, Cause.Done>({ capacity: tool.emitBufferSize })
|
|
364
|
+
const emit = (event: unknown) => Effect.asVoid(Queue.offer(queue, event))
|
|
365
|
+
const fiber = yield* tool
|
|
366
|
+
.run(validated.value, emit)
|
|
367
|
+
.pipe(Effect.ensuring(Queue.end(queue)), Effect.forkScoped)
|
|
368
|
+
|
|
369
|
+
const progress = Stream.fromQueue(queue).pipe(
|
|
370
|
+
Stream.map((data) => ToolEvent.Progress({ call_id: call.call_id, tool: tool.name, data })),
|
|
133
371
|
)
|
|
134
372
|
const output = Stream.fromEffect(
|
|
135
|
-
|
|
136
|
-
Effect.map((
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
373
|
+
Fiber.join(fiber).pipe(
|
|
374
|
+
Effect.map((value) => ToolEvent.Output({ result: okResult(call, tool.name, value) })),
|
|
375
|
+
Effect.catchCause(() =>
|
|
376
|
+
Effect.succeed(
|
|
377
|
+
ToolEvent.Output({ result: executionError(call, "Tool execution failed") }),
|
|
378
|
+
),
|
|
140
379
|
),
|
|
141
380
|
),
|
|
142
381
|
)
|
|
143
382
|
return progress.pipe(Stream.concat(output))
|
|
144
383
|
}),
|
|
145
384
|
).pipe(
|
|
385
|
+
// Backstop for the input-parsing failures (`json_parse_error`,
|
|
386
|
+
// `validation_threw`) and any defect before the run fiber is forked.
|
|
146
387
|
Stream.catchCause(() =>
|
|
147
|
-
Stream.succeed(
|
|
148
|
-
ToolEvent.Output({
|
|
149
|
-
result: executionError(call, "Tool execution failed"),
|
|
150
|
-
}),
|
|
151
|
-
),
|
|
388
|
+
Stream.succeed(ToolEvent.Output({ result: executionError(call, "Tool execution failed") })),
|
|
152
389
|
),
|
|
153
390
|
)
|
|
154
391
|
|
|
@@ -167,7 +404,7 @@ const runStreaming = <R>(
|
|
|
167
404
|
* the results to wire-format `ToolCallOutput`s. Curried so it slots directly
|
|
168
405
|
* into `continueWithResults`:
|
|
169
406
|
*
|
|
170
|
-
* Toolkit.run(
|
|
407
|
+
* Toolkit.run(toolkit, calls).pipe(
|
|
171
408
|
* Toolkit.continueWithResults(Toolkit.appendToolResults(state, turn)),
|
|
172
409
|
* )
|
|
173
410
|
*
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { Context, type DateTime, Effect, Schema } from "effect"
|
|
2
|
+
import type * as AiError from "../domain/AiError.js"
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Recency
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Convenience freshness enum shared by every provider. A coarse "published
|
|
10
|
+
* within the last …" filter that maps onto each provider's native recency
|
|
11
|
+
* knob (Perplexity `search_recency_filter`, Brave `freshness`, Tavily
|
|
12
|
+
* `time_range`, You.com `freshness`), or onto a computed published-date
|
|
13
|
+
* range where a provider has no enum (Exa).
|
|
14
|
+
*
|
|
15
|
+
* For a precise window use {@link CommonSearchRequest.startDate} /
|
|
16
|
+
* {@link CommonSearchRequest.endDate} instead; `recency` is the sugar for
|
|
17
|
+
* the common "past week / past month" case.
|
|
18
|
+
*
|
|
19
|
+
* Exposed as a `Schema` so {@link "../web-search/WebSearchTool".webSearchTool}
|
|
20
|
+
* can hand it straight to a model as a tool parameter; the
|
|
21
|
+
* {@link SearchRecency} type is derived from it.
|
|
22
|
+
*/
|
|
23
|
+
export const SearchRecency = Schema.Literals(["hour", "day", "week", "month", "year"])
|
|
24
|
+
|
|
25
|
+
export type SearchRecency = typeof SearchRecency.Type
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Request
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Cross-provider web-search request. A field earns a place here only if
|
|
33
|
+
* most of the supported providers honor it and a developer would expect it
|
|
34
|
+
* from a generic search interface; everything provider-specific (Exa
|
|
35
|
+
* `type`, Tavily `searchDepth`, Brave `goggles`, pagination `offset`,
|
|
36
|
+
* `safeSearch`, `topic`, …) lives on that provider's typed request.
|
|
37
|
+
*
|
|
38
|
+
* Note the one structural difference from `EmbeddingModel` /
|
|
39
|
+
* `Transcriber`: there is no `model`. None of the pure-`/search` endpoints
|
|
40
|
+
* take one - what looks model-shaped on a provider (Exa `type`, Tavily
|
|
41
|
+
* `searchDepth`) is a mode knob, so it stays on the provider-typed request.
|
|
42
|
+
*/
|
|
43
|
+
export type CommonSearchRequest = {
|
|
44
|
+
/** The search query. The one field every provider requires. */
|
|
45
|
+
readonly query: string
|
|
46
|
+
/**
|
|
47
|
+
* Upper bound on the number of results to return in this one call.
|
|
48
|
+
* Providers cap differently (Perplexity / Tavily / Brave at ~20, Exa /
|
|
49
|
+
* You.com at ~100); a value above a provider's cap is clamped by that
|
|
50
|
+
* provider. There is no common pagination - only two providers paginate
|
|
51
|
+
* and only shallowly, so `offset` is provider-typed.
|
|
52
|
+
*/
|
|
53
|
+
readonly maxResults?: number
|
|
54
|
+
/** Restrict results to these domains (e.g. `["docs.foo.com"]`). */
|
|
55
|
+
readonly includeDomains?: ReadonlyArray<string>
|
|
56
|
+
/** Drop results from these domains. */
|
|
57
|
+
readonly excludeDomains?: ReadonlyArray<string>
|
|
58
|
+
/**
|
|
59
|
+
* Coarse freshness filter - see {@link SearchRecency}. Sugar over an
|
|
60
|
+
* explicit `startDate`; ignored where {@link startDate} is also set and
|
|
61
|
+
* the provider takes only one.
|
|
62
|
+
*/
|
|
63
|
+
readonly recency?: SearchRecency
|
|
64
|
+
/** Published-after bound. The precise alternative to {@link recency}. */
|
|
65
|
+
readonly startDate?: DateTime.DateTime
|
|
66
|
+
/** Published-before bound. */
|
|
67
|
+
readonly endDate?: DateTime.DateTime
|
|
68
|
+
/** ISO 3166-1 alpha-2 country code to localize results (e.g. `"us"`). */
|
|
69
|
+
readonly country?: string
|
|
70
|
+
/** ISO 639-1 language code to bias results toward (e.g. `"en"`). */
|
|
71
|
+
readonly language?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Response
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* One normalized web-search hit. A flat record with optionals rather than a
|
|
80
|
+
* tagged union: the providers differ by *which fields are present*, not by
|
|
81
|
+
* *kind*. The provider's untouched result object is always on `raw`, so a
|
|
82
|
+
* caller holding a typed provider tag can recover anything not promoted
|
|
83
|
+
* here (`author`, `favicon`, multi-chunk `snippets[]`, …).
|
|
84
|
+
*/
|
|
85
|
+
export type SearchResult = {
|
|
86
|
+
/** Canonical result URL. Always present. */
|
|
87
|
+
readonly url: string
|
|
88
|
+
/** Result title / headline, when the provider returns one. */
|
|
89
|
+
readonly title?: string
|
|
90
|
+
/**
|
|
91
|
+
* Primary short excerpt - the one text field all providers return (under
|
|
92
|
+
* various wire names: `snippet`, `content`, `description`). The richer
|
|
93
|
+
* extract fields (`text`, `highlights`, `summary`) belong to the
|
|
94
|
+
* out-of-scope extract feature and stay on the provider-typed result.
|
|
95
|
+
*/
|
|
96
|
+
readonly snippet?: string
|
|
97
|
+
/** Publication date, when the provider reports one. */
|
|
98
|
+
readonly publishedDate?: DateTime.DateTime
|
|
99
|
+
/**
|
|
100
|
+
* Relevance score, only from providers that rank (Exa, Tavily);
|
|
101
|
+
* `undefined` for providers that return an unscored list. Kept despite
|
|
102
|
+
* being non-universal because ranking is core to what a "search result"
|
|
103
|
+
* means - and `undefined` cleanly says "this backend doesn't score."
|
|
104
|
+
*/
|
|
105
|
+
readonly score?: number
|
|
106
|
+
/** The provider-native result object, never lossy. */
|
|
107
|
+
readonly raw: unknown
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type SearchResponse = {
|
|
111
|
+
readonly results: ReadonlyArray<SearchResult>
|
|
112
|
+
/**
|
|
113
|
+
* The provider-native top-level response object. Cost / usage reporting
|
|
114
|
+
* is deliberately not modeled on this surface yet: providers disagree on
|
|
115
|
+
* both what (Exa returns USD, Tavily credits, Brave nothing in-body) and
|
|
116
|
+
* where (Brave reports cost in response headers), so it is deferred to the
|
|
117
|
+
* unified usage design (see plans/usage-tracking.md). Anything a provider
|
|
118
|
+
* reports survives here on `raw`.
|
|
119
|
+
*/
|
|
120
|
+
readonly raw: unknown
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Service + helper
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The portable web-search surface: one operation, `search`. Pure search is
|
|
129
|
+
* the one thing every provider does, so - unlike `SpeechSynthesizer` /
|
|
130
|
+
* `Transcriber` - this capability needs no marker tags; every provider
|
|
131
|
+
* Layer can answer.
|
|
132
|
+
*/
|
|
133
|
+
export type WebSearchService = {
|
|
134
|
+
readonly search: (request: CommonSearchRequest) => Effect.Effect<SearchResponse, AiError.AiError>
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Generic web-search service tag. Yield this for provider-portable code;
|
|
139
|
+
* yield a provider tag (`PerplexitySearch`, `ExaSearch`, …) when you need
|
|
140
|
+
* that provider's own knobs. A provider's `layer` registers both.
|
|
141
|
+
*/
|
|
142
|
+
export class WebSearch extends Context.Service<WebSearch, WebSearchService>()(
|
|
143
|
+
"@betalyra/effect-uai/WebSearch",
|
|
144
|
+
) {}
|
|
145
|
+
|
|
146
|
+
/** Run one web search against whichever provider Layer is in scope. */
|
|
147
|
+
export const search = (
|
|
148
|
+
request: CommonSearchRequest,
|
|
149
|
+
): Effect.Effect<SearchResponse, AiError.AiError, WebSearch> =>
|
|
150
|
+
Effect.flatMap(WebSearch.asEffect(), (s) => s.search(request))
|