@effect-uai/core 0.4.0 → 0.5.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-csR8Bhxx.d.mts → AiError-CAX_48RU.d.mts} +2 -2
- package/dist/{AiError-csR8Bhxx.d.mts.map → AiError-CAX_48RU.d.mts.map} +1 -1
- package/dist/{Image-DxyXqzAM.d.mts → Image-HNmMpMTh.d.mts} +4 -4
- package/dist/{Image-DxyXqzAM.d.mts.map → Image-HNmMpMTh.d.mts.map} +1 -1
- package/dist/{Items-Hg5AsYxl.d.mts → Items-DqbaJoz7.d.mts} +8 -8
- package/dist/{Items-Hg5AsYxl.d.mts.map → Items-DqbaJoz7.d.mts.map} +1 -1
- package/dist/{StructuredFormat-Cl41C56K.d.mts → StructuredFormat-BbN4dosH.d.mts} +11 -4
- package/dist/StructuredFormat-BbN4dosH.d.mts.map +1 -0
- package/dist/{Tool-B8B5qVEy.d.mts → Tool-Y0__Py1H.d.mts} +20 -4
- package/dist/Tool-Y0__Py1H.d.mts.map +1 -0
- package/dist/Turn-ChbL2foc.d.mts +388 -0
- package/dist/Turn-ChbL2foc.d.mts.map +1 -0
- package/dist/domain/AiError.d.mts +1 -1
- package/dist/domain/AiError.mjs +1 -1
- package/dist/domain/AiError.mjs.map +1 -1
- package/dist/domain/Image.d.mts +1 -1
- package/dist/domain/Items.d.mts +1 -1
- package/dist/domain/Items.mjs +1 -1
- package/dist/domain/Items.mjs.map +1 -1
- package/dist/domain/Turn.d.mts +2 -2
- package/dist/domain/Turn.mjs +22 -4
- package/dist/domain/Turn.mjs.map +1 -1
- package/dist/domain/Turn.test.d.mts +1 -0
- package/dist/domain/Turn.test.mjs +136 -0
- package/dist/domain/Turn.test.mjs.map +1 -0
- package/dist/embedding-model/Embedding.d.mts +15 -3
- package/dist/embedding-model/Embedding.d.mts.map +1 -1
- package/dist/embedding-model/Embedding.mjs.map +1 -1
- package/dist/embedding-model/EmbeddingModel.d.mts +33 -17
- package/dist/embedding-model/EmbeddingModel.d.mts.map +1 -1
- package/dist/embedding-model/EmbeddingModel.mjs.map +1 -1
- package/dist/embedding-model/EmbeddingModel.test.d.mts +1 -0
- package/dist/embedding-model/EmbeddingModel.test.mjs +59 -0
- package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -0
- package/dist/index.d.mts +6 -6
- package/dist/language-model/LanguageModel.d.mts +30 -8
- package/dist/language-model/LanguageModel.d.mts.map +1 -1
- package/dist/language-model/LanguageModel.mjs +33 -3
- package/dist/language-model/LanguageModel.mjs.map +1 -1
- package/dist/language-model/LanguageModel.test.d.mts +1 -0
- package/dist/language-model/LanguageModel.test.mjs +143 -0
- package/dist/language-model/LanguageModel.test.mjs.map +1 -0
- package/dist/loop/Loop.d.mts +94 -11
- package/dist/loop/Loop.d.mts.map +1 -1
- package/dist/loop/Loop.mjs +92 -26
- package/dist/loop/Loop.mjs.map +1 -1
- package/dist/loop/Loop.test.mjs +171 -3
- package/dist/loop/Loop.test.mjs.map +1 -1
- package/dist/music-generator/MusicGenerator.d.mts +1 -1
- package/dist/observability/Metrics.d.mts +1 -1
- package/dist/observability/Metrics.mjs +1 -1
- package/dist/observability/Metrics.mjs.map +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +1 -1
- package/dist/streaming/JSONL.d.mts +1 -1
- package/dist/streaming/JSONL.d.mts.map +1 -1
- package/dist/streaming/JSONL.mjs +7 -12
- package/dist/streaming/JSONL.mjs.map +1 -1
- package/dist/structured-format/StructuredFormat.d.mts +2 -2
- package/dist/structured-format/StructuredFormat.mjs +9 -1
- package/dist/structured-format/StructuredFormat.mjs.map +1 -1
- package/dist/structured-format/StructuredFormat.test.d.mts +1 -0
- package/dist/structured-format/StructuredFormat.test.mjs +70 -0
- package/dist/structured-format/StructuredFormat.test.mjs.map +1 -0
- package/dist/testing/MockMusicGenerator.d.mts.map +1 -1
- package/dist/testing/MockMusicGenerator.mjs +2 -2
- package/dist/testing/MockMusicGenerator.mjs.map +1 -1
- package/dist/testing/MockProvider.d.mts +23 -18
- package/dist/testing/MockProvider.d.mts.map +1 -1
- package/dist/testing/MockProvider.mjs +56 -72
- package/dist/testing/MockProvider.mjs.map +1 -1
- package/dist/testing/MockSpeechSynthesizer.d.mts.map +1 -1
- package/dist/testing/MockSpeechSynthesizer.mjs +2 -2
- package/dist/testing/MockSpeechSynthesizer.mjs.map +1 -1
- package/dist/testing/MockTranscriber.d.mts.map +1 -1
- package/dist/testing/MockTranscriber.mjs +2 -2
- package/dist/testing/MockTranscriber.mjs.map +1 -1
- package/dist/tool/HistoryCheck.d.mts +1 -1
- package/dist/tool/Outcome.d.mts +1 -1
- package/dist/tool/Resolvers.d.mts +65 -8
- package/dist/tool/Resolvers.d.mts.map +1 -1
- package/dist/tool/Resolvers.mjs +8 -12
- package/dist/tool/Resolvers.mjs.map +1 -1
- package/dist/tool/Resolvers.test.mjs +6 -5
- package/dist/tool/Resolvers.test.mjs.map +1 -1
- package/dist/tool/Tool.d.mts +2 -2
- package/dist/tool/Tool.mjs +18 -1
- package/dist/tool/Tool.mjs.map +1 -1
- package/dist/tool/Tool.test.d.mts +1 -0
- package/dist/tool/Tool.test.mjs +66 -0
- package/dist/tool/Tool.test.mjs.map +1 -0
- package/dist/tool/Toolkit.d.mts +4 -6
- package/dist/tool/Toolkit.d.mts.map +1 -1
- package/dist/tool/Toolkit.mjs +14 -43
- package/dist/tool/Toolkit.mjs.map +1 -1
- package/dist/transcriber/Transcriber.d.mts +1 -1
- package/package.json +1 -1
- package/src/domain/AiError.ts +1 -1
- package/src/domain/Items.ts +1 -1
- package/src/domain/Turn.test.ts +141 -0
- package/src/domain/Turn.ts +50 -43
- package/src/embedding-model/Embedding.ts +23 -0
- package/src/embedding-model/EmbeddingModel.test.ts +92 -0
- package/src/embedding-model/EmbeddingModel.ts +30 -20
- package/src/language-model/LanguageModel.test.ts +170 -0
- package/src/language-model/LanguageModel.ts +64 -1
- package/src/loop/Loop.test.ts +256 -3
- package/src/loop/Loop.ts +225 -49
- package/src/observability/Metrics.ts +1 -1
- package/src/streaming/JSONL.ts +9 -18
- package/src/structured-format/StructuredFormat.test.ts +105 -0
- package/src/structured-format/StructuredFormat.ts +14 -1
- package/src/testing/MockMusicGenerator.ts +4 -6
- package/src/testing/MockProvider.ts +126 -105
- package/src/testing/MockSpeechSynthesizer.ts +4 -6
- package/src/testing/MockTranscriber.ts +4 -6
- package/src/tool/Resolvers.test.ts +8 -5
- package/src/tool/Resolvers.ts +17 -19
- package/src/tool/Tool.test.ts +105 -0
- package/src/tool/Tool.ts +20 -0
- package/src/tool/Toolkit.ts +49 -50
- package/dist/StructuredFormat-Cl41C56K.d.mts.map +0 -1
- package/dist/Tool-B8B5qVEy.d.mts.map +0 -1
- package/dist/Turn-7geUcKsf.d.mts +0 -194
- package/dist/Turn-7geUcKsf.d.mts.map +0 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { Duration, Effect, Layer, Ref, Schedule, Stream } from "effect"
|
|
1
|
+
import { Array as Arr, Duration, Effect, Layer, Match, Option, Ref, Schedule, Stream } from "effect"
|
|
2
2
|
import * as AiError from "../domain/AiError.js"
|
|
3
|
-
import type
|
|
3
|
+
import { type Item, isOutputText } from "../domain/Items.js"
|
|
4
4
|
import { LanguageModel, type LanguageModelService } from "../language-model/LanguageModel.js"
|
|
5
|
-
import type
|
|
5
|
+
import { type Turn, TurnEvent } from "../domain/Turn.js"
|
|
6
6
|
|
|
7
7
|
export type MockOptions = {
|
|
8
8
|
/**
|
|
@@ -13,46 +13,48 @@ export type MockOptions = {
|
|
|
13
13
|
readonly deltaInterval?: Duration.Input
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export type Call = {
|
|
17
|
+
readonly history: ReadonlyArray<Item>
|
|
18
|
+
readonly turn: Turn
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
/**
|
|
17
22
|
* A scripted mock provider. Pre-canned `Turn` outputs are returned in order,
|
|
18
23
|
* one per call to `streamTurn`. Each scripted turn is split into synthetic
|
|
19
|
-
* deltas (text →
|
|
24
|
+
* deltas (text → ToolCallStart → ToolCallArgsDelta → ... → TurnComplete)
|
|
20
25
|
* so streaming consumers can see realistic delta shapes.
|
|
21
26
|
*/
|
|
22
27
|
export type MockRecorder = {
|
|
23
|
-
readonly calls: ReadonlyArray<
|
|
24
|
-
readonly history: ReadonlyArray<Item>
|
|
25
|
-
readonly turn: Turn
|
|
26
|
-
}>
|
|
28
|
+
readonly calls: ReadonlyArray<Call>
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Pure projection: Turn → ReadonlyArray<TurnEvent>
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
const itemToDeltas: (item: Item) => ReadonlyArray<TurnEvent> = Match.type<Item>().pipe(
|
|
36
|
+
Match.discriminators("type")({
|
|
37
|
+
message: (m): ReadonlyArray<TurnEvent> =>
|
|
38
|
+
m.role === "assistant"
|
|
39
|
+
? m.content.filter(isOutputText).map((b) => TurnEvent.TextDelta({ text: b.text }))
|
|
40
|
+
: [],
|
|
41
|
+
function_call: (fc) => [
|
|
42
|
+
TurnEvent.ToolCallStart({ call_id: fc.call_id, name: fc.name }),
|
|
43
|
+
TurnEvent.ToolCallArgsDelta({ call_id: fc.call_id, delta: fc.arguments }),
|
|
44
|
+
],
|
|
45
|
+
function_call_output: () => [],
|
|
46
|
+
reasoning: (r) =>
|
|
47
|
+
r.summary !== undefined
|
|
48
|
+
? [TurnEvent.ReasoningDelta({ text: r.summary, kind: "summary" as const })]
|
|
49
|
+
: [],
|
|
50
|
+
}),
|
|
51
|
+
Match.exhaustive,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const turnToDeltas = (turn: Turn): ReadonlyArray<TurnEvent> => [
|
|
55
|
+
...turn.items.flatMap(itemToDeltas),
|
|
56
|
+
TurnEvent.TurnComplete({ turn }),
|
|
57
|
+
]
|
|
56
58
|
|
|
57
59
|
const pacedDeltas = (turn: Turn, options?: MockOptions): Stream.Stream<TurnEvent> => {
|
|
58
60
|
const base = Stream.fromIterable(turnToDeltas(turn))
|
|
@@ -61,101 +63,120 @@ const pacedDeltas = (turn: Turn, options?: MockOptions): Stream.Stream<TurnEvent
|
|
|
61
63
|
: base.pipe(Stream.schedule(Schedule.spaced(options.deltaInterval)))
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Canonical service factory. One implementation; sync/Layer/recorder
|
|
68
|
+
// variants below are just different ways to wire the cursor + record hook.
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
const exhausted = (n: number, attempt: number): AiError.AiError =>
|
|
72
|
+
new AiError.InvalidRequest({
|
|
73
|
+
provider: "mock",
|
|
74
|
+
raw: `MockProvider exhausted: ${n} turns scripted, but call ${attempt} was made`,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const noRecord = (_: Call): Effect.Effect<void> => Effect.void
|
|
78
|
+
|
|
79
|
+
const buildService = (
|
|
65
80
|
scriptedTurns: ReadonlyArray<Turn>,
|
|
66
|
-
options
|
|
67
|
-
|
|
68
|
-
) =>
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
Effect.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
)
|
|
83
|
-
}
|
|
84
|
-
const turn = scriptedTurns[i]!
|
|
85
|
-
if (recordCall !== undefined) {
|
|
86
|
-
yield* recordCall(request.history, turn)
|
|
87
|
-
}
|
|
88
|
-
return pacedDeltas(turn, options)
|
|
89
|
-
}),
|
|
81
|
+
options: MockOptions | undefined,
|
|
82
|
+
cursor: Ref.Ref<number>,
|
|
83
|
+
record: (call: Call) => Effect.Effect<void>,
|
|
84
|
+
): LanguageModelService => ({
|
|
85
|
+
streamTurn: (request) =>
|
|
86
|
+
Stream.unwrap(
|
|
87
|
+
Ref.getAndUpdate(cursor, (n) => n + 1).pipe(
|
|
88
|
+
Effect.flatMap(
|
|
89
|
+
(i): Effect.Effect<Stream.Stream<TurnEvent, AiError.AiError>> =>
|
|
90
|
+
Option.match(Arr.get(scriptedTurns, i), {
|
|
91
|
+
onNone: () => Effect.succeed(Stream.fail(exhausted(scriptedTurns.length, i + 1))),
|
|
92
|
+
onSome: (turn) =>
|
|
93
|
+
record({ history: request.history, turn }).pipe(
|
|
94
|
+
Effect.as(pacedDeltas(turn, options)),
|
|
95
|
+
),
|
|
96
|
+
}),
|
|
90
97
|
),
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Recorder handle. Unsafe Ref is local: it backs both the `record` write
|
|
104
|
+
// hook (called inside the service) and the `recorder` read effect (called
|
|
105
|
+
// by the test). Both close over the same cell.
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
type RecorderHandle = {
|
|
109
|
+
readonly record: (call: Call) => Effect.Effect<void>
|
|
110
|
+
readonly recorder: Effect.Effect<MockRecorder>
|
|
111
|
+
}
|
|
93
112
|
|
|
113
|
+
const makeRecorderUnsafe = (): RecorderHandle => {
|
|
114
|
+
const ref = Ref.makeUnsafe<ReadonlyArray<Call>>([])
|
|
115
|
+
return {
|
|
116
|
+
record: (call) => Ref.update(ref, Arr.append(call)),
|
|
117
|
+
recorder: Ref.get(ref).pipe(Effect.map((calls): MockRecorder => ({ calls }))),
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Public API
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Layer that registers a `MockProvider` against the `LanguageModel` tag.
|
|
127
|
+
* Calls beyond the scripted turn count fail with `InvalidRequest`.
|
|
128
|
+
*/
|
|
94
129
|
export const layer = (
|
|
95
130
|
scriptedTurns: ReadonlyArray<Turn>,
|
|
96
131
|
options?: MockOptions,
|
|
97
|
-
): Layer.Layer<LanguageModel> =>
|
|
132
|
+
): Layer.Layer<LanguageModel> =>
|
|
133
|
+
Layer.effect(
|
|
134
|
+
LanguageModel,
|
|
135
|
+
Ref.make(0).pipe(
|
|
136
|
+
Effect.map((cursor) => buildService(scriptedTurns, options, cursor, noRecord)),
|
|
137
|
+
),
|
|
138
|
+
)
|
|
98
139
|
|
|
99
140
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* mid-stream via `Effect.provideService` instead of providing one model
|
|
103
|
-
* for the whole program via `Layer`.
|
|
141
|
+
* Like `layer`, but also exposes a recorder that captures every call
|
|
142
|
+
* (history + returned turn).
|
|
104
143
|
*/
|
|
105
|
-
export const
|
|
144
|
+
export const layerWithRecorder = (
|
|
106
145
|
scriptedTurns: ReadonlyArray<Turn>,
|
|
107
146
|
options?: MockOptions,
|
|
108
147
|
): {
|
|
109
|
-
readonly
|
|
148
|
+
readonly layer: Layer.Layer<LanguageModel>
|
|
110
149
|
readonly recorder: Effect.Effect<MockRecorder>
|
|
111
150
|
} => {
|
|
112
|
-
const
|
|
113
|
-
const callsRef = Ref.makeUnsafe<ReadonlyArray<{ history: ReadonlyArray<Item>; turn: Turn }>>([])
|
|
114
|
-
const service: LanguageModelService = {
|
|
115
|
-
streamTurn: (request) =>
|
|
116
|
-
Stream.unwrap(
|
|
117
|
-
Effect.gen(function* () {
|
|
118
|
-
const i = yield* Ref.getAndUpdate(cursor, (n) => n + 1)
|
|
119
|
-
if (i >= scriptedTurns.length) {
|
|
120
|
-
return Stream.fail(
|
|
121
|
-
new AiError.InvalidRequest({
|
|
122
|
-
provider: "mock",
|
|
123
|
-
raw: `MockProvider exhausted: ${scriptedTurns.length} turns scripted, but call ${i + 1} was made`,
|
|
124
|
-
}),
|
|
125
|
-
)
|
|
126
|
-
}
|
|
127
|
-
const turn = scriptedTurns[i]!
|
|
128
|
-
yield* Ref.update(callsRef, (xs) => [...xs, { history: request.history, turn }])
|
|
129
|
-
return pacedDeltas(turn, options)
|
|
130
|
-
}),
|
|
131
|
-
),
|
|
132
|
-
}
|
|
151
|
+
const { record, recorder } = makeRecorderUnsafe()
|
|
133
152
|
return {
|
|
134
|
-
|
|
135
|
-
|
|
153
|
+
layer: Layer.effect(
|
|
154
|
+
LanguageModel,
|
|
155
|
+
Ref.make(0).pipe(
|
|
156
|
+
Effect.map((cursor) => buildService(scriptedTurns, options, cursor, record)),
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
recorder,
|
|
136
160
|
}
|
|
137
161
|
}
|
|
138
162
|
|
|
139
163
|
/**
|
|
140
|
-
*
|
|
141
|
-
*
|
|
164
|
+
* Build the `LanguageModelService` value directly (no Layer), plus a
|
|
165
|
+
* recorder. Use this when you want to swap models mid-program via
|
|
166
|
+
* `Effect.provideService` instead of providing one model for the whole
|
|
167
|
+
* program via `Layer`.
|
|
142
168
|
*/
|
|
143
|
-
export const
|
|
169
|
+
export const make = (
|
|
144
170
|
scriptedTurns: ReadonlyArray<Turn>,
|
|
145
171
|
options?: MockOptions,
|
|
146
172
|
): {
|
|
147
|
-
readonly
|
|
173
|
+
readonly service: LanguageModelService
|
|
148
174
|
readonly recorder: Effect.Effect<MockRecorder>
|
|
149
175
|
} => {
|
|
150
|
-
const
|
|
151
|
-
const
|
|
152
|
-
LanguageModel,
|
|
153
|
-
makeService(scriptedTurns, options, (history, turn) =>
|
|
154
|
-
Ref.update(callsRef, (xs) => [...xs, { history, turn }]),
|
|
155
|
-
),
|
|
156
|
-
)
|
|
176
|
+
const cursor = Ref.makeUnsafe(0)
|
|
177
|
+
const { record, recorder } = makeRecorderUnsafe()
|
|
157
178
|
return {
|
|
158
|
-
|
|
159
|
-
recorder
|
|
179
|
+
service: buildService(scriptedTurns, options, cursor, record),
|
|
180
|
+
recorder,
|
|
160
181
|
}
|
|
161
182
|
}
|
|
@@ -43,12 +43,10 @@ const makeService = (
|
|
|
43
43
|
const i = yield* Ref.getAndUpdate(bCursor, (n) => n + 1)
|
|
44
44
|
const scripted = script.blobs ?? []
|
|
45
45
|
if (i >= scripted.length) {
|
|
46
|
-
return yield*
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}),
|
|
51
|
-
)
|
|
46
|
+
return yield* new AiError.InvalidRequest({
|
|
47
|
+
provider: "mock",
|
|
48
|
+
raw: `MockSpeechSynthesizer exhausted: ${scripted.length} blobs scripted, but call ${i + 1} was made`,
|
|
49
|
+
})
|
|
52
50
|
}
|
|
53
51
|
return scripted[i]!
|
|
54
52
|
}),
|
|
@@ -41,12 +41,10 @@ const makeService = (
|
|
|
41
41
|
const i = yield* Ref.getAndUpdate(tCursor, (n) => n + 1)
|
|
42
42
|
const scripted = script.transcripts ?? []
|
|
43
43
|
if (i >= scripted.length) {
|
|
44
|
-
return yield*
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}),
|
|
49
|
-
)
|
|
44
|
+
return yield* new AiError.InvalidRequest({
|
|
45
|
+
provider: "mock",
|
|
46
|
+
raw: `MockTranscriber exhausted: ${scripted.length} transcripts scripted, but call ${i + 1} was made`,
|
|
47
|
+
})
|
|
50
48
|
}
|
|
51
49
|
return scripted[i]!
|
|
52
50
|
}),
|
|
@@ -22,8 +22,8 @@ import {
|
|
|
22
22
|
fromVerdictQueue,
|
|
23
23
|
} from "./Resolvers.js"
|
|
24
24
|
import { fromEffectSchema, make as makeTool, streaming } from "./Tool.js"
|
|
25
|
-
import { executeAll
|
|
26
|
-
import {
|
|
25
|
+
import { executeAll } from "./Toolkit.js"
|
|
26
|
+
import { ToolEvent, isApprovalRequested, isIntermediate, isOutput } from "./ToolEvent.js"
|
|
27
27
|
|
|
28
28
|
// ---------------------------------------------------------------------------
|
|
29
29
|
// Three demo tools covering the matrix:
|
|
@@ -91,15 +91,18 @@ const resultsFrom = (collected: ReadonlyArray<ToolEvent>): ReadonlyArray<ToolRes
|
|
|
91
91
|
|
|
92
92
|
const byCallId = (results: ReadonlyArray<ToolResult>) => new Map(results.map((r) => [r.call_id, r]))
|
|
93
93
|
|
|
94
|
+
const rejectedStream = (rejected: ReadonlyArray<ToolResult>) =>
|
|
95
|
+
Stream.fromIterable(rejected.map((result) => ToolEvent.Output({ result })))
|
|
96
|
+
|
|
94
97
|
const eventsFromApprovalMap = (approvals: ReadonlyMap<string, ApprovalMapEntry>) => {
|
|
95
98
|
const plan = fromApprovalMap(isSensitive, approvals)(calls)
|
|
96
|
-
return Stream.merge(executeAll(allTools, plan.approved),
|
|
99
|
+
return Stream.merge(executeAll(allTools, plan.approved), rejectedStream(plan.rejected))
|
|
97
100
|
}
|
|
98
101
|
|
|
99
102
|
const eventsFromDecision = (decision: ToolCallDecision): Stream.Stream<ToolEvent> =>
|
|
100
103
|
decision._tag === "Approved"
|
|
101
104
|
? executeAll(allTools, [decision.call])
|
|
102
|
-
: Stream.succeed(
|
|
105
|
+
: Stream.succeed(ToolEvent.Output({ result: decision.result }))
|
|
103
106
|
|
|
104
107
|
// ---------------------------------------------------------------------------
|
|
105
108
|
// fromApprovalMap: HTTP-style scenarios
|
|
@@ -189,7 +192,7 @@ describe("executeAll: graceful degradation", () => {
|
|
|
189
192
|
isSensitive,
|
|
190
193
|
new Map([["c3", { decision: "approve" }]]),
|
|
191
194
|
)(callsWithBogus)
|
|
192
|
-
return Stream.merge(executeAll(allTools, plan.approved),
|
|
195
|
+
return Stream.merge(executeAll(allTools, plan.approved), rejectedStream(plan.rejected))
|
|
193
196
|
})(),
|
|
194
197
|
),
|
|
195
198
|
)
|
package/src/tool/Resolvers.ts
CHANGED
|
@@ -5,29 +5,27 @@
|
|
|
5
5
|
* results must be returned to the model. Tool execution stays explicit at
|
|
6
6
|
* the recipe boundary via `Toolkit.executeAll`.
|
|
7
7
|
*/
|
|
8
|
-
import { Deferred, Effect, Queue, Scope, Stream } from "effect"
|
|
8
|
+
import { Data, Deferred, Effect, Queue, Scope, Stream } from "effect"
|
|
9
9
|
import type { FunctionCall } from "../domain/Items.js"
|
|
10
10
|
import { type ToolResult, cancelled, denied } from "./Outcome.js"
|
|
11
|
-
import
|
|
11
|
+
import { ToolEvent } from "./ToolEvent.js"
|
|
12
12
|
|
|
13
13
|
export type ToolCallPlan = {
|
|
14
14
|
readonly approved: ReadonlyArray<FunctionCall>
|
|
15
15
|
readonly rejected: ReadonlyArray<ToolResult>
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export type ToolCallDecision =
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
export type ToolCallDecision = Data.TaggedEnum<{
|
|
19
|
+
Approved: { readonly call: FunctionCall }
|
|
20
|
+
Rejected: { readonly result: ToolResult }
|
|
21
|
+
}>
|
|
21
22
|
|
|
22
|
-
export const
|
|
23
|
-
_tag: "Approved",
|
|
24
|
-
call,
|
|
25
|
-
})
|
|
23
|
+
export const ToolCallDecision = Data.taggedEnum<ToolCallDecision>()
|
|
26
24
|
|
|
27
|
-
export const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
})
|
|
25
|
+
export const approve = (call: FunctionCall): ToolCallDecision => ToolCallDecision.Approved({ call })
|
|
26
|
+
|
|
27
|
+
export const reject = (result: ToolResult): ToolCallDecision =>
|
|
28
|
+
ToolCallDecision.Rejected({ result })
|
|
31
29
|
|
|
32
30
|
export const splitToolCallDecisions = (decisions: ReadonlyArray<ToolCallDecision>): ToolCallPlan =>
|
|
33
31
|
decisions.reduce<ToolCallPlan>(
|
|
@@ -38,12 +36,12 @@ export const splitToolCallDecisions = (decisions: ReadonlyArray<ToolCallDecision
|
|
|
38
36
|
{ approved: [], rejected: [] },
|
|
39
37
|
)
|
|
40
38
|
|
|
41
|
-
export const approvalRequested = (call: FunctionCall): ToolEvent =>
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
})
|
|
39
|
+
export const approvalRequested = (call: FunctionCall): ToolEvent =>
|
|
40
|
+
ToolEvent.ApprovalRequested({
|
|
41
|
+
call_id: call.call_id,
|
|
42
|
+
tool: call.name,
|
|
43
|
+
arguments: call.arguments,
|
|
44
|
+
})
|
|
47
45
|
|
|
48
46
|
// ---------------------------------------------------------------------------
|
|
49
47
|
// Verdict queue (WebSocket-style transport).
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { describe, expect, expectTypeOf, it } from "vitest"
|
|
4
|
+
import * as Tool from "./Tool.js"
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Minimal dual-standard schema for testing — no need to pull Zod / Valibot /
|
|
8
|
+
// ArkType as devDeps. This is the smallest possible object satisfying both
|
|
9
|
+
// `StandardSchemaV1` and `StandardJSONSchemaV1` per their published specs.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
type EmailRecipient = { readonly to: string }
|
|
13
|
+
|
|
14
|
+
const emailRecipientSchema: StandardSchemaV1<unknown, EmailRecipient> &
|
|
15
|
+
StandardJSONSchemaV1<unknown, EmailRecipient> = {
|
|
16
|
+
"~standard": {
|
|
17
|
+
version: 1,
|
|
18
|
+
vendor: "test-fixture",
|
|
19
|
+
validate: (value) => {
|
|
20
|
+
if (
|
|
21
|
+
typeof value === "object" &&
|
|
22
|
+
value !== null &&
|
|
23
|
+
"to" in value &&
|
|
24
|
+
typeof (value as { to: unknown }).to === "string"
|
|
25
|
+
) {
|
|
26
|
+
return { value: value as EmailRecipient }
|
|
27
|
+
}
|
|
28
|
+
return { issues: [{ message: "expected { to: string }" }] }
|
|
29
|
+
},
|
|
30
|
+
jsonSchema: {
|
|
31
|
+
input: () => ({
|
|
32
|
+
type: "object",
|
|
33
|
+
properties: { to: { type: "string" } },
|
|
34
|
+
required: ["to"],
|
|
35
|
+
}),
|
|
36
|
+
output: () => ({
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: { to: { type: "string" } },
|
|
39
|
+
required: ["to"],
|
|
40
|
+
}),
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A schema that satisfies StandardSchemaV1 only (no JSON Schema). Used to
|
|
46
|
+
// verify the helper's compile-time guard.
|
|
47
|
+
const standardOnly: StandardSchemaV1<unknown, EmailRecipient> = {
|
|
48
|
+
"~standard": {
|
|
49
|
+
version: 1,
|
|
50
|
+
vendor: "test-fixture",
|
|
51
|
+
validate: () => ({ value: { to: "" } }),
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("Tool.fromStandardSchema", () => {
|
|
56
|
+
it("returns the schema (structurally) typed as ToolInputSchema<Output>", () => {
|
|
57
|
+
const adapted = Tool.fromStandardSchema(emailRecipientSchema)
|
|
58
|
+
|
|
59
|
+
// Same object — helper is a type-narrowing identity at runtime.
|
|
60
|
+
expect(adapted).toBe(emailRecipientSchema)
|
|
61
|
+
|
|
62
|
+
// Both interfaces accessible through the same `~standard` key.
|
|
63
|
+
const valid = adapted["~standard"].validate({ to: "hi@example.com" })
|
|
64
|
+
expect(valid).toEqual({ value: { to: "hi@example.com" } })
|
|
65
|
+
|
|
66
|
+
const json = adapted["~standard"].jsonSchema.input({ target: "draft-2020-12" })
|
|
67
|
+
expect(json).toEqual({
|
|
68
|
+
type: "object",
|
|
69
|
+
properties: { to: { type: "string" } },
|
|
70
|
+
required: ["to"],
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("composes with Tool.make so Input is inferred from the schema's Output", async () => {
|
|
75
|
+
const sendEmail = Tool.make({
|
|
76
|
+
name: "send_email",
|
|
77
|
+
description: "Send an email to a single recipient.",
|
|
78
|
+
inputSchema: Tool.fromStandardSchema(emailRecipientSchema),
|
|
79
|
+
run: ({ to }) => Effect.succeed(`queued: ${to}`),
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// `run`'s parameter is typed as { to: string } via the schema's Output —
|
|
83
|
+
// this property access compiles without annotation.
|
|
84
|
+
const result = await Effect.runPromise(sendEmail.run({ to: "x@y.z" }))
|
|
85
|
+
expect(result).toBe("queued: x@y.z")
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("type: rejects schemas missing the Standard JSON Schema half at compile time", () => {
|
|
89
|
+
// @ts-expect-error — `standardOnly` lacks `jsonSchema`; helper's
|
|
90
|
+
// intersection constraint refuses it.
|
|
91
|
+
Tool.fromStandardSchema(standardOnly)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("type: Output type flows through fromStandardSchema into Tool.make", () => {
|
|
95
|
+
const tool = Tool.make({
|
|
96
|
+
name: "send_email",
|
|
97
|
+
description: "send",
|
|
98
|
+
inputSchema: Tool.fromStandardSchema(emailRecipientSchema),
|
|
99
|
+
run: (input) => Effect.succeed(input),
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
type InputOf<T> = T extends Tool.Tool<string, infer I, unknown, never> ? I : never
|
|
103
|
+
expectTypeOf<InputOf<typeof tool>>().toEqualTypeOf<EmailRecipient>()
|
|
104
|
+
})
|
|
105
|
+
})
|
package/src/tool/Tool.ts
CHANGED
|
@@ -36,6 +36,26 @@ export const fromEffectSchema = <S extends Schema.Codec<any, any, never, any>>(
|
|
|
36
36
|
Schema.toStandardJSONSchemaV1(Schema.toStandardSchemaV1(schema)) as unknown as S &
|
|
37
37
|
ToolInputSchema<S["Type"]>
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Use any schema library that implements both Standard Schema (validation)
|
|
41
|
+
* and Standard JSON Schema (JSON Schema generation) as a `Tool.inputSchema`.
|
|
42
|
+
* Covers Zod 4.2+, Valibot 1.2+, and ArkType 2.1.28+ in one helper.
|
|
43
|
+
*
|
|
44
|
+
* Effect Schema doesn't implement Standard JSON Schema natively — use
|
|
45
|
+
* `fromEffectSchema` for those.
|
|
46
|
+
*
|
|
47
|
+
* The intersection constraint catches missing interfaces at compile time:
|
|
48
|
+
* a Zod v3 schema (no Standard JSON Schema) produces a precise type error
|
|
49
|
+
* pointing at the missing interface rather than a runtime surprise. The
|
|
50
|
+
* helper itself is a thin type-narrowing identity — schemas that satisfy
|
|
51
|
+
* both standards already structurally satisfy `ToolInputSchema`; the
|
|
52
|
+
* helper makes the input type inference explicit at the call site.
|
|
53
|
+
*/
|
|
54
|
+
export const fromStandardSchema = <S extends StandardSchemaV1 & StandardJSONSchemaV1>(
|
|
55
|
+
schema: S,
|
|
56
|
+
): S & ToolInputSchema<StandardSchemaV1.InferOutput<S>> =>
|
|
57
|
+
schema as S & ToolInputSchema<StandardSchemaV1.InferOutput<S>>
|
|
58
|
+
|
|
39
59
|
export type Tool<Name extends string, Input, Output, R = never> = {
|
|
40
60
|
readonly name: Name
|
|
41
61
|
readonly description: string
|