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