@effect-uai/core 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{AiError-CrdR1EQi.d.mts → AiError-Bf1pq4BB.d.mts} +8 -3
- package/dist/{AiError-CrdR1EQi.d.mts.map → AiError-Bf1pq4BB.d.mts.map} +1 -1
- package/dist/{Audio-DDSKCkSe.d.mts → Audio-CR7CSljJ.d.mts} +2 -2
- package/dist/{Audio-DDSKCkSe.d.mts.map → Audio-CR7CSljJ.d.mts.map} +1 -1
- package/dist/{Image-C_Zjpwhc.d.mts → Image-C4vuBmu0.d.mts} +12 -12
- package/dist/{Image-C_Zjpwhc.d.mts.map → Image-C4vuBmu0.d.mts.map} +1 -1
- package/dist/{Items-CgKD6Npw.d.mts → Items-BWNu6VGp.d.mts} +118 -92
- package/dist/Items-BWNu6VGp.d.mts.map +1 -0
- package/dist/{Loop-B5_cqumi.d.mts → Loop-D8nWpowc.d.mts} +2 -2
- package/dist/{Loop-B5_cqumi.d.mts.map → Loop-D8nWpowc.d.mts.map} +1 -1
- package/dist/{Media-DOLyaygI.d.mts → Media-D_CpcM1Z.d.mts} +1 -1
- package/dist/{Media-DOLyaygI.d.mts.map → Media-D_CpcM1Z.d.mts.map} +1 -1
- package/dist/{StructuredFormat-CMJFD3sn.d.mts → StructuredFormat-DHaUJBdd.d.mts} +1 -1
- package/dist/{StructuredFormat-CMJFD3sn.d.mts.map → StructuredFormat-DHaUJBdd.d.mts.map} +1 -1
- package/dist/{Tool-0XUIT2A2.d.mts → Tool-CXFHCQrT.d.mts} +101 -16
- package/dist/Tool-CXFHCQrT.d.mts.map +1 -0
- package/dist/{ToolEvent-DYrAUJGs.d.mts → ToolEvent-nVijCrvm.d.mts} +2 -2
- package/dist/ToolEvent-nVijCrvm.d.mts.map +1 -0
- package/dist/{ToolResult-BZd-cEWW.d.mts → ToolResult-CQqKkcDa.d.mts} +10 -4
- package/dist/ToolResult-CQqKkcDa.d.mts.map +1 -0
- package/dist/{Toolkit-BKC8b8Xo.d.mts → Toolkit-BYPwwu-j.d.mts} +65 -24
- package/dist/Toolkit-BYPwwu-j.d.mts.map +1 -0
- package/dist/browser/Browser.d.mts +190 -0
- package/dist/browser/Browser.d.mts.map +1 -0
- package/dist/browser/Browser.mjs +34 -0
- package/dist/browser/Browser.mjs.map +1 -0
- package/dist/browser/BrowserError.d.mts +141 -0
- package/dist/browser/BrowserError.d.mts.map +1 -0
- package/dist/browser/BrowserError.mjs +89 -0
- package/dist/browser/BrowserError.mjs.map +1 -0
- package/dist/browser/BrowserTool.d.mts +59 -0
- package/dist/browser/BrowserTool.d.mts.map +1 -0
- package/dist/browser/BrowserTool.mjs +86 -0
- package/dist/browser/BrowserTool.mjs.map +1 -0
- package/dist/{dist-DWgRbDN3.mjs → dist-C0m0QnUP.mjs} +71 -30
- package/dist/dist-C0m0QnUP.mjs.map +1 -0
- package/dist/{dist-B04dlFHg.mjs → dist-CcRV3-A5.mjs} +8 -8
- package/dist/{dist-B04dlFHg.mjs.map → dist-CcRV3-A5.mjs.map} +1 -1
- package/dist/domain/AiError.d.mts +2 -2
- package/dist/domain/AiError.mjs +22 -3
- package/dist/domain/AiError.mjs.map +1 -1
- package/dist/domain/Audio.d.mts +1 -1
- package/dist/domain/Citation.d.mts +60 -0
- package/dist/domain/Citation.d.mts.map +1 -0
- package/dist/domain/Citation.mjs +7 -0
- package/dist/domain/Citation.mjs.map +1 -0
- package/dist/domain/Image.d.mts +1 -1
- package/dist/domain/Items.d.mts +1 -1
- package/dist/domain/Items.mjs +13 -3
- package/dist/domain/Items.mjs.map +1 -1
- package/dist/domain/Media.d.mts +1 -1
- package/dist/domain/Music.d.mts +1 -1
- package/dist/domain/Research.d.mts +37 -0
- package/dist/domain/Research.d.mts.map +1 -0
- package/dist/domain/Research.mjs +7 -0
- package/dist/domain/Research.mjs.map +1 -0
- package/dist/domain/Turn.d.mts +92 -10
- package/dist/domain/Turn.d.mts.map +1 -1
- package/dist/domain/Turn.mjs +8 -1
- package/dist/domain/Turn.mjs.map +1 -1
- 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.mjs +2 -2
- package/dist/embedding-model/EmbeddingModel.mjs.map +1 -1
- package/dist/embedding-model/EmbeddingModel.test.mjs +1 -1
- package/dist/index.d.mts +22 -13
- package/dist/index.mjs +18 -9
- package/dist/job/Job.d.mts +80 -0
- package/dist/job/Job.d.mts.map +1 -0
- package/dist/job/Job.mjs +49 -0
- package/dist/job/Job.mjs.map +1 -0
- package/dist/language-model/LanguageModel.d.mts +4 -4
- package/dist/language-model/LanguageModel.mjs +2 -2
- package/dist/language-model/LanguageModel.mjs.map +1 -1
- package/dist/language-model/LanguageModel.test.mjs +1 -1
- package/dist/loop/Loop.d.mts +1 -1
- package/dist/loop/Loop.test.mjs +1 -1
- package/dist/music-generator/MusicGenerator.d.mts +2 -2
- package/dist/music-generator/MusicGenerator.mjs +4 -4
- package/dist/music-generator/MusicGenerator.mjs.map +1 -1
- package/dist/music-generator/MusicGenerator.test.mjs +1 -1
- package/dist/observability/Metrics.d.mts +1 -1
- package/dist/observability/Metrics.test.mjs +2 -2
- package/dist/observability/Telemetry.test.mjs +2 -2
- package/dist/research/DeepResearch.d.mts +71 -0
- package/dist/research/DeepResearch.d.mts.map +1 -0
- package/dist/research/DeepResearch.mjs +61 -0
- package/dist/research/DeepResearch.mjs.map +1 -0
- package/dist/retry/Retry.d.mts +1 -1
- package/dist/retry/Retry.test.mjs +1 -1
- package/dist/sandbox/Sandbox.mjs +18 -18
- package/dist/sandbox/Sandbox.mjs.map +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +2 -2
- package/dist/speech-synthesizer/SpeechSynthesizer.mjs +8 -8
- package/dist/speech-synthesizer/SpeechSynthesizer.mjs.map +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +1 -1
- package/dist/streaming/JSONL.d.mts +3 -1
- package/dist/streaming/JSONL.d.mts.map +1 -1
- package/dist/streaming/JSONL.mjs +5 -11
- package/dist/streaming/JSONL.mjs.map +1 -1
- package/dist/streaming/JSONL.test.mjs +10 -1
- package/dist/streaming/JSONL.test.mjs.map +1 -1
- package/dist/streaming/SSE.d.mts +5 -3
- package/dist/streaming/SSE.d.mts.map +1 -1
- package/dist/streaming/SSE.mjs +28 -38
- package/dist/streaming/SSE.mjs.map +1 -1
- package/dist/streaming/SSE.test.mjs +54 -4
- package/dist/streaming/SSE.test.mjs.map +1 -1
- package/dist/structured-format/StructuredFormat.d.mts +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 +19 -7
- package/dist/tool/Approval.d.mts.map +1 -1
- package/dist/tool/Approval.mjs +18 -11
- package/dist/tool/Approval.mjs.map +1 -1
- package/dist/tool/Approval.test.mjs +47 -8
- 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 -28
- package/dist/tool/Tool.mjs.map +1 -1
- package/dist/tool/Tool.test.mjs +127 -3
- package/dist/tool/Tool.test.mjs.map +1 -1
- package/dist/tool/ToolEvent.d.mts +1 -1
- package/dist/tool/ToolEvent.mjs +5 -0
- package/dist/tool/ToolEvent.mjs.map +1 -1
- package/dist/tool/ToolResult.d.mts +2 -2
- package/dist/tool/ToolResult.mjs +15 -6
- package/dist/tool/ToolResult.mjs.map +1 -1
- package/dist/tool/Toolkit.d.mts +2 -2
- package/dist/tool/Toolkit.mjs +62 -22
- package/dist/tool/Toolkit.mjs.map +1 -1
- package/dist/tool/Toolkit.test.mjs +207 -9
- package/dist/tool/Toolkit.test.mjs.map +1 -1
- package/dist/transcriber/Transcriber.d.mts +2 -2
- package/dist/transcriber/Transcriber.mjs +3 -3
- package/dist/transcriber/Transcriber.mjs.map +1 -1
- package/dist/transcriber/Transcriber.test.mjs +1 -1
- package/dist/web-read/WebRead.d.mts +44 -0
- package/dist/web-read/WebRead.d.mts.map +1 -0
- package/dist/web-read/WebRead.mjs +18 -0
- package/dist/web-read/WebRead.mjs.map +1 -0
- package/dist/web-read/WebReadTool.d.mts +57 -0
- package/dist/web-read/WebReadTool.d.mts.map +1 -0
- package/dist/web-read/WebReadTool.mjs +59 -0
- package/dist/web-read/WebReadTool.mjs.map +1 -0
- package/dist/web-search/WebSearch.d.mts +1 -1
- package/dist/web-search/WebSearch.mjs +1 -1
- package/dist/web-search/WebSearch.mjs.map +1 -1
- package/dist/web-search/WebSearchTool.d.mts +12 -6
- package/dist/web-search/WebSearchTool.d.mts.map +1 -1
- package/dist/web-search/WebSearchTool.mjs +10 -5
- package/dist/web-search/WebSearchTool.mjs.map +1 -1
- package/package.json +39 -3
- package/src/browser/Browser.ts +268 -0
- package/src/browser/BrowserError.ts +145 -0
- package/src/browser/BrowserTool.ts +161 -0
- package/src/domain/AiError.ts +27 -1
- package/src/domain/Citation.ts +64 -0
- package/src/domain/Items.ts +14 -4
- package/src/domain/Research.ts +42 -0
- package/src/domain/Turn.ts +26 -0
- package/src/embedding-model/EmbeddingModel.ts +2 -2
- package/src/index.ts +9 -0
- package/src/job/Job.ts +125 -0
- package/src/language-model/LanguageModel.ts +2 -2
- package/src/music-generator/MusicGenerator.ts +4 -4
- package/src/research/DeepResearch.ts +137 -0
- package/src/sandbox/Sandbox.ts +18 -18
- package/src/speech-synthesizer/SpeechSynthesizer.ts +8 -8
- package/src/streaming/JSONL.test.ts +19 -0
- package/src/streaming/JSONL.ts +6 -38
- package/src/streaming/SSE.test.ts +86 -3
- package/src/streaming/SSE.ts +36 -80
- package/src/tool/Approval.test.ts +55 -5
- package/src/tool/Approval.ts +72 -27
- package/src/tool/Tool.test.ts +151 -1
- package/src/tool/Tool.ts +263 -66
- package/src/tool/ToolEvent.ts +5 -0
- package/src/tool/ToolResult.ts +14 -8
- package/src/tool/Toolkit.test.ts +231 -2
- package/src/tool/Toolkit.ts +175 -62
- package/src/transcriber/Transcriber.ts +3 -3
- package/src/web-read/WebRead.ts +70 -0
- package/src/web-read/WebReadTool.ts +96 -0
- package/src/web-search/WebSearch.ts +1 -1
- package/src/web-search/WebSearchTool.ts +11 -5
- package/dist/Items-CgKD6Npw.d.mts.map +0 -1
- package/dist/Tool-0XUIT2A2.d.mts.map +0 -1
- package/dist/ToolEvent-DYrAUJGs.d.mts.map +0 -1
- package/dist/ToolResult-BZd-cEWW.d.mts.map +0 -1
- package/dist/Toolkit-BKC8b8Xo.d.mts.map +0 -1
- package/dist/dist-DWgRbDN3.mjs.map +0 -1
package/src/streaming/SSE.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Stream } from "effect"
|
|
2
|
+
import { Sse } from "effect/unstable/encoding"
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* One Server-Sent Event. Fields per the WHATWG spec:
|
|
5
|
-
* - `event`: optional event name (default "message"
|
|
6
|
+
* - `event`: optional event name (absent for the default "message")
|
|
6
7
|
* - `data`: payload, with multiple `data:` lines joined by `\n`
|
|
7
8
|
* - `id`: optional last-event id
|
|
8
9
|
*/
|
|
@@ -12,98 +13,44 @@ export type Event = {
|
|
|
12
13
|
readonly id?: string
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
const toEvent = (wire: Sse.Event): Event => ({
|
|
17
|
+
data: wire.data,
|
|
18
|
+
...(wire.event !== "message" && { event: wire.event }),
|
|
19
|
+
...(wire.id !== undefined && { id: wire.id }),
|
|
20
|
+
})
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
Stream.mapAccum(
|
|
24
|
-
(): TextDecoder => new TextDecoder("utf-8"),
|
|
25
|
-
(decoder, chunk: Uint8Array) => [decoder, [decoder.decode(chunk, { stream: true })]] as const,
|
|
26
|
-
{
|
|
27
|
-
onHalt: (decoder: TextDecoder) => {
|
|
28
|
-
const tail = decoder.decode()
|
|
29
|
-
return tail.length > 0 ? [tail] : []
|
|
30
|
-
},
|
|
31
|
-
},
|
|
32
|
-
),
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
/** Split a text stream on a separator, buffering across chunk boundaries. */
|
|
36
|
-
const splitOn =
|
|
37
|
-
(separator: string) =>
|
|
38
|
-
<E, R>(self: Stream.Stream<string, E, R>): Stream.Stream<string, E, R> =>
|
|
39
|
-
self.pipe(
|
|
40
|
-
Stream.mapAccum(
|
|
41
|
-
(): string => "",
|
|
42
|
-
(buffer, chunk: string) => {
|
|
43
|
-
const parts = (buffer + chunk).split(separator)
|
|
44
|
-
const tail = parts[parts.length - 1] ?? ""
|
|
45
|
-
return [tail, parts.slice(0, -1)] as const
|
|
46
|
-
},
|
|
47
|
-
{ onHalt: (tail: string) => (tail.length > 0 ? [tail] : []) },
|
|
48
|
-
),
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
|
-
// Parser
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
|
|
55
|
-
const parseField = (line: string): readonly [string, string] => {
|
|
56
|
-
const colon = line.indexOf(":")
|
|
57
|
-
if (colon < 0) return [line, ""]
|
|
58
|
-
const value = line.slice(colon + 1)
|
|
59
|
-
return [line.slice(0, colon), value.startsWith(" ") ? value.slice(1) : value]
|
|
22
|
+
type ParserState = {
|
|
23
|
+
readonly parser: Sse.Parser
|
|
24
|
+
readonly out: Array<Event>
|
|
60
25
|
}
|
|
61
26
|
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const id = fields.find(([f]) => f === "id")?.[1]
|
|
70
|
-
|
|
71
|
-
const out: { event?: string; data: string; id?: string } = {
|
|
72
|
-
data: dataLines.join("\n"),
|
|
73
|
-
}
|
|
74
|
-
if (event !== undefined) out.event = event
|
|
75
|
-
if (id !== undefined) out.id = id
|
|
76
|
-
return out as Event
|
|
27
|
+
const makeParserState = (): ParserState => {
|
|
28
|
+
const out: Array<Event> = []
|
|
29
|
+
const parser = Sse.makeParser((event) => {
|
|
30
|
+
// `retry:` reconnection hints have no representation in `Event`
|
|
31
|
+
if (event._tag === "Event") out.push(toEvent(event))
|
|
32
|
+
})
|
|
33
|
+
return { parser, out }
|
|
77
34
|
}
|
|
78
35
|
|
|
79
|
-
// ---------------------------------------------------------------------------
|
|
80
|
-
// Public API
|
|
81
|
-
// ---------------------------------------------------------------------------
|
|
82
|
-
|
|
83
36
|
/**
|
|
84
37
|
* Decode a `Stream<Uint8Array>` (e.g. an HTTP response body) into a
|
|
85
|
-
* `Stream<SSE.Event
|
|
86
|
-
*
|
|
38
|
+
* `Stream<SSE.Event>`, using the spec parser from `effect/unstable/encoding`.
|
|
39
|
+
* An event is dispatched only once its terminating blank line arrives, so a
|
|
40
|
+
* partial event at stream end (clean or failed) is discarded per the spec
|
|
41
|
+
* rather than surfaced as a truncated frame.
|
|
87
42
|
*/
|
|
88
43
|
export const fromBytes = <E, R>(
|
|
89
44
|
self: Stream.Stream<Uint8Array, E, R>,
|
|
90
45
|
): Stream.Stream<Event, E, R> =>
|
|
91
46
|
self.pipe(
|
|
92
|
-
decodeText,
|
|
93
|
-
Stream.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
47
|
+
Stream.decodeText,
|
|
48
|
+
Stream.mapAccum(makeParserState, (state, text: string) => {
|
|
49
|
+
state.parser.feed(text)
|
|
50
|
+
return [state, state.out.splice(0)] as const
|
|
51
|
+
}),
|
|
97
52
|
)
|
|
98
53
|
|
|
99
|
-
const eventToString = (ev: Event): string => {
|
|
100
|
-
const parts: string[] = []
|
|
101
|
-
if (ev.event !== undefined) parts.push(`event: ${ev.event}`)
|
|
102
|
-
if (ev.id !== undefined) parts.push(`id: ${ev.id}`)
|
|
103
|
-
for (const line of ev.data.split("\n")) parts.push(`data: ${line}`)
|
|
104
|
-
return parts.join("\n") + "\n\n"
|
|
105
|
-
}
|
|
106
|
-
|
|
107
54
|
const encoder = new TextEncoder()
|
|
108
55
|
|
|
109
56
|
/**
|
|
@@ -111,4 +58,13 @@ const encoder = new TextEncoder()
|
|
|
111
58
|
* HTTP response with `Content-Type: text/event-stream`.
|
|
112
59
|
*/
|
|
113
60
|
export const toBytes = <E, R>(self: Stream.Stream<Event, E, R>): Stream.Stream<Uint8Array, E, R> =>
|
|
114
|
-
Stream.map(self, (ev) =>
|
|
61
|
+
Stream.map(self, (ev) =>
|
|
62
|
+
encoder.encode(
|
|
63
|
+
Sse.encoder.write({
|
|
64
|
+
_tag: "Event",
|
|
65
|
+
event: ev.event ?? "message",
|
|
66
|
+
id: ev.id,
|
|
67
|
+
data: ev.data,
|
|
68
|
+
}),
|
|
69
|
+
),
|
|
70
|
+
)
|
|
@@ -15,7 +15,13 @@ import { describe, expect, it } from "vitest"
|
|
|
15
15
|
import * as Items from "../domain/Items.js"
|
|
16
16
|
import { findUnansweredCalls, cancelAllPending, isReconciled } from "./HistoryCheck.js"
|
|
17
17
|
import { type ToolResult, isFailure, isOk, toToolCallOutput } from "./ToolResult.js"
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
type ApprovalMapEntry,
|
|
20
|
+
type ApprovalDecision,
|
|
21
|
+
type Verdict,
|
|
22
|
+
fromMap,
|
|
23
|
+
fromQueue,
|
|
24
|
+
} from "./Approval.js"
|
|
19
25
|
import { fromEffectSchema, make as makeTool } from "./Tool.js"
|
|
20
26
|
import { make as makeToolkit, run } from "./Toolkit.js"
|
|
21
27
|
import { ToolEvent, isApprovalRequested, isProgress, isOutput } from "./ToolEvent.js"
|
|
@@ -249,6 +255,50 @@ describe("fromQueue + run", () => {
|
|
|
249
255
|
reason: "too risky",
|
|
250
256
|
})
|
|
251
257
|
})
|
|
258
|
+
|
|
259
|
+
it("router retires per round, so sequential rounds over one queue both resolve", async () => {
|
|
260
|
+
// A verdict-per-round over one shared queue and scope. The old
|
|
261
|
+
// never-terminating router would still be alive for round 1 and steal
|
|
262
|
+
// round 2's verdict (not in its map), hanging round 2.
|
|
263
|
+
const decisions = await Effect.runPromise(
|
|
264
|
+
Effect.scoped(
|
|
265
|
+
Effect.gen(function* () {
|
|
266
|
+
const verdicts = yield* Queue.unbounded<Verdict>()
|
|
267
|
+
const round = (id: string) => [fc(id, "delete_database", { name: "prod" })]
|
|
268
|
+
|
|
269
|
+
const r1 = yield* fromQueue(isSensitive, verdicts)(round("c1"))
|
|
270
|
+
yield* Queue.offer(verdicts, { call_id: "c1", decision: "approve" })
|
|
271
|
+
const d1 = Array.from(yield* Stream.runCollect(r1.decisions))
|
|
272
|
+
|
|
273
|
+
const r2 = yield* fromQueue(isSensitive, verdicts)(round("c2"))
|
|
274
|
+
yield* Queue.offer(verdicts, { call_id: "c2", decision: "deny", reason: "no" })
|
|
275
|
+
const d2 = Array.from(yield* Stream.runCollect(r2.decisions))
|
|
276
|
+
|
|
277
|
+
return { d1, d2 }
|
|
278
|
+
}),
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
expect(decisions.d1[0]).toMatchObject({ _tag: "Approved" })
|
|
282
|
+
expect(decisions.d2[0]).toMatchObject({ _tag: "Rejected", result: { kind: "denied" } })
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it("timeout resolves an unanswered gated call as cancelled", async () => {
|
|
286
|
+
const decisions = await Effect.runPromise(
|
|
287
|
+
Effect.scoped(
|
|
288
|
+
Effect.gen(function* () {
|
|
289
|
+
const verdicts = yield* Queue.unbounded<Verdict>()
|
|
290
|
+
const { decisions } = yield* fromQueue(isSensitive, verdicts, { timeout: "10 millis" })([
|
|
291
|
+
fc("c1", "delete_database", { name: "prod" }),
|
|
292
|
+
])
|
|
293
|
+
return Array.from(yield* Stream.runCollect(decisions))
|
|
294
|
+
}),
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
expect(decisions[0]).toMatchObject({
|
|
298
|
+
_tag: "Rejected",
|
|
299
|
+
result: { _tag: "Failure", kind: "cancelled" },
|
|
300
|
+
})
|
|
301
|
+
})
|
|
252
302
|
})
|
|
253
303
|
|
|
254
304
|
// ---------------------------------------------------------------------------
|
|
@@ -313,7 +363,7 @@ describe("toToolCallOutput", () => {
|
|
|
313
363
|
expect(JSON.parse(out.output)).toEqual({ count: 3 })
|
|
314
364
|
})
|
|
315
365
|
|
|
316
|
-
it("
|
|
366
|
+
it("renders a Failure with reason as a nested error object", () => {
|
|
317
367
|
const r: ToolResult = {
|
|
318
368
|
_tag: "Failure",
|
|
319
369
|
call_id: "c2",
|
|
@@ -322,10 +372,10 @@ describe("toToolCallOutput", () => {
|
|
|
322
372
|
reason: "spam concern",
|
|
323
373
|
}
|
|
324
374
|
const out = toToolCallOutput(r)
|
|
325
|
-
expect(JSON.parse(out.output)).toEqual({ kind: "denied",
|
|
375
|
+
expect(JSON.parse(out.output)).toEqual({ error: { kind: "denied", message: "spam concern" } })
|
|
326
376
|
})
|
|
327
377
|
|
|
328
|
-
it("
|
|
378
|
+
it("renders a Failure without reason (omits message)", () => {
|
|
329
379
|
const r: ToolResult = {
|
|
330
380
|
_tag: "Failure",
|
|
331
381
|
call_id: "c3",
|
|
@@ -333,7 +383,7 @@ describe("toToolCallOutput", () => {
|
|
|
333
383
|
kind: "cancelled",
|
|
334
384
|
}
|
|
335
385
|
const out = toToolCallOutput(r)
|
|
336
|
-
expect(JSON.parse(out.output)).toEqual({ kind: "cancelled" })
|
|
386
|
+
expect(JSON.parse(out.output)).toEqual({ error: { kind: "cancelled" } })
|
|
337
387
|
})
|
|
338
388
|
})
|
|
339
389
|
|
package/src/tool/Approval.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* results must be returned to the model. Tool execution stays explicit at
|
|
6
6
|
* the recipe boundary via `Toolkit.run`.
|
|
7
7
|
*/
|
|
8
|
-
import { Data, Deferred, Effect, Queue, Scope, Stream } from "effect"
|
|
8
|
+
import { Data, Deferred, type Duration, Effect, Queue, Ref, Scope, Stream } from "effect"
|
|
9
9
|
import type { ToolCall } from "../domain/Items.js"
|
|
10
10
|
import { type ToolResult, cancelled, denied } from "./ToolResult.js"
|
|
11
11
|
import { ToolEvent } from "./ToolEvent.js"
|
|
@@ -53,13 +53,30 @@ export type Verdict = {
|
|
|
53
53
|
readonly reason?: string
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export type FromQueueOptions = {
|
|
57
|
+
/**
|
|
58
|
+
* Resolve any still-pending gated call as `cancelled` once this elapses,
|
|
59
|
+
* so a lost or never-sent verdict cannot hang the decisions stream
|
|
60
|
+
* forever. Omit to wait indefinitely.
|
|
61
|
+
*/
|
|
62
|
+
readonly timeout?: Duration.Input
|
|
63
|
+
}
|
|
64
|
+
|
|
56
65
|
/**
|
|
57
66
|
* Queue-backed approval planner. Safe calls are returned immediately in
|
|
58
67
|
* `approved`; gated calls emit `ApprovalRequested` events and later produce
|
|
59
68
|
* one `ApprovalDecision` when their matching verdict arrives.
|
|
69
|
+
*
|
|
70
|
+
* The verdict queue is a single-consumer transport: run one approval round
|
|
71
|
+
* at a time (the router retires once every gated call of the round is
|
|
72
|
+
* resolved). For overlapping rounds, use one queue per round.
|
|
60
73
|
*/
|
|
61
74
|
export const fromQueue =
|
|
62
|
-
(
|
|
75
|
+
(
|
|
76
|
+
predicate: (call: ToolCall) => boolean,
|
|
77
|
+
verdicts: Queue.Dequeue<Verdict>,
|
|
78
|
+
options?: FromQueueOptions,
|
|
79
|
+
) =>
|
|
63
80
|
(
|
|
64
81
|
calls: ReadonlyArray<ToolCall>,
|
|
65
82
|
): Effect.Effect<
|
|
@@ -76,37 +93,65 @@ export const fromQueue =
|
|
|
76
93
|
const approved = calls.filter((call) => !predicate(call))
|
|
77
94
|
|
|
78
95
|
const entries = yield* Effect.forEach(gated, (call) =>
|
|
79
|
-
Deferred.make<
|
|
96
|
+
Deferred.make<ApprovalDecision>().pipe(Effect.map((d) => [call.call_id, d] as const)),
|
|
80
97
|
)
|
|
81
|
-
const deferreds: ReadonlyMap<string, Deferred.Deferred<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
const deferreds: ReadonlyMap<string, Deferred.Deferred<ApprovalDecision>> = new Map(entries)
|
|
99
|
+
const callById: ReadonlyMap<string, ToolCall> = new Map(gated.map((c) => [c.call_id, c]))
|
|
100
|
+
const outstanding = yield* Ref.make(gated.length)
|
|
101
|
+
|
|
102
|
+
// Complete a deferred once; decrement only on the winning transition so
|
|
103
|
+
// a late verdict for an already-resolved call is a no-op.
|
|
104
|
+
const resolve = (call: ToolCall, decision: ApprovalDecision) =>
|
|
105
|
+
Deferred.succeed(deferreds.get(call.call_id)!, decision).pipe(
|
|
106
|
+
Effect.flatMap((won) => (won ? Ref.update(outstanding, (n) => n - 1) : Effect.void)),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
// Router retires once every gated call is resolved (by verdict or
|
|
110
|
+
// timeout), so sequential rounds over one queue do not accumulate
|
|
111
|
+
// racing routers. Recursive + suspended: stack-safe, no imperative loop.
|
|
112
|
+
const route: Effect.Effect<void> = Effect.suspend(() =>
|
|
113
|
+
Ref.get(outstanding).pipe(
|
|
114
|
+
Effect.flatMap((n) =>
|
|
115
|
+
n <= 0
|
|
116
|
+
? Effect.void
|
|
117
|
+
: Queue.take(verdicts).pipe(
|
|
118
|
+
Effect.flatMap((v) => {
|
|
119
|
+
const call = callById.get(v.call_id)
|
|
120
|
+
return (
|
|
121
|
+
call === undefined
|
|
122
|
+
? Effect.void
|
|
123
|
+
: resolve(
|
|
124
|
+
call,
|
|
125
|
+
v.decision === "approve"
|
|
126
|
+
? approve(call)
|
|
127
|
+
: reject(denied(call, v.reason)),
|
|
128
|
+
)
|
|
129
|
+
).pipe(Effect.andThen(route))
|
|
130
|
+
}),
|
|
131
|
+
),
|
|
132
|
+
),
|
|
93
133
|
),
|
|
94
134
|
)
|
|
95
135
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
Effect.
|
|
103
|
-
|
|
136
|
+
if (gated.length > 0) {
|
|
137
|
+
yield* Effect.forkScoped(route)
|
|
138
|
+
if (options?.timeout !== undefined) {
|
|
139
|
+
yield* Effect.forkScoped(
|
|
140
|
+
Effect.sleep(options.timeout).pipe(
|
|
141
|
+
Effect.andThen(
|
|
142
|
+
Effect.forEach(gated, (call) =>
|
|
143
|
+
resolve(call, reject(cancelled(call, "approval timed out"))),
|
|
104
144
|
),
|
|
105
145
|
),
|
|
106
|
-
)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const decisions = Stream.fromIterable(gated).pipe(
|
|
152
|
+
Stream.flatMap((call) => Stream.fromEffect(Deferred.await(deferreds.get(call.call_id)!)), {
|
|
153
|
+
concurrency: "unbounded",
|
|
154
|
+
}),
|
|
110
155
|
)
|
|
111
156
|
|
|
112
157
|
const approvalRequests = Stream.fromIterable<ToolEvent>(gated.map(approvalRequested))
|
package/src/tool/Tool.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
|
|
2
|
-
import { Effect } from "effect"
|
|
2
|
+
import { Context, Effect, Schema } from "effect"
|
|
3
3
|
import { describe, expect, expectTypeOf, it } from "vitest"
|
|
4
4
|
import type * as Items from "../domain/Items.js"
|
|
5
5
|
import * as Tool from "./Tool.js"
|
|
@@ -156,3 +156,153 @@ describe("Tool.decodeArgs", () => {
|
|
|
156
156
|
expect(exit._tag).toBe("Failure")
|
|
157
157
|
})
|
|
158
158
|
})
|
|
159
|
+
|
|
160
|
+
describe("Effect Schema inputSchema", () => {
|
|
161
|
+
const Recipient = Schema.Struct({ to: Schema.String })
|
|
162
|
+
|
|
163
|
+
const sendEmail = Tool.make({
|
|
164
|
+
name: "send_email",
|
|
165
|
+
description: "Send an email to a single recipient.",
|
|
166
|
+
inputSchema: Recipient,
|
|
167
|
+
run: ({ to }) => Effect.succeed(`queued: ${to}`),
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const callWith = (args: string): Items.ToolCall => ({
|
|
171
|
+
type: "function_call",
|
|
172
|
+
call_id: "call_1",
|
|
173
|
+
name: "send_email",
|
|
174
|
+
arguments: args,
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("runs with the input inferred from the schema's Type", async () => {
|
|
178
|
+
const result = await Effect.runPromise(sendEmail.run({ to: "x@y.z" }, () => Effect.void))
|
|
179
|
+
expect(result).toBe("queued: x@y.z")
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it("type: Input is the schema's Type", () => {
|
|
183
|
+
type InputOf<T> = T extends Tool.Tool<string, infer I, any, any, any> ? I : never
|
|
184
|
+
expectTypeOf<InputOf<typeof sendEmail>>().toEqualTypeOf<{ readonly to: string }>()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it("type: E and R are inferred from run through the Effect Schema overload", () => {
|
|
188
|
+
class Mailer extends Context.Service<
|
|
189
|
+
Mailer,
|
|
190
|
+
{ readonly send: (to: string) => Effect.Effect<string> }
|
|
191
|
+
>()("test/Mailer") {}
|
|
192
|
+
|
|
193
|
+
const tool = Tool.make({
|
|
194
|
+
name: "send_email",
|
|
195
|
+
description: "send",
|
|
196
|
+
inputSchema: Recipient,
|
|
197
|
+
run: ({ to }) =>
|
|
198
|
+
Effect.gen(function* () {
|
|
199
|
+
const mailer = yield* Mailer
|
|
200
|
+
if (to === "") return yield* Effect.fail("empty" as const)
|
|
201
|
+
return yield* mailer.send(to)
|
|
202
|
+
}),
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
expectTypeOf<Tool.ToolE<typeof tool>>().toEqualTypeOf<"empty">()
|
|
206
|
+
expectTypeOf<Tool.ToolR<typeof tool>>().toEqualTypeOf<Mailer>()
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it("decodes valid JSON arguments to the typed input", async () => {
|
|
210
|
+
const input = await Effect.runPromise(Tool.decodeArgs(sendEmail, callWith('{"to":"x@y.z"}')))
|
|
211
|
+
expect(input).toEqual({ to: "x@y.z" })
|
|
212
|
+
expectTypeOf(input).toEqualTypeOf<{ readonly to: string }>()
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it("fails with ToolValidationError when the arguments don't satisfy the schema", async () => {
|
|
216
|
+
const exit = await Effect.runPromiseExit(Tool.decodeArgs(sendEmail, callWith('{"to":42}')))
|
|
217
|
+
expect(exit._tag).toBe("Failure")
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it("renders a JSON Schema descriptor from the wrapped schema", () => {
|
|
221
|
+
const [descriptor] = Tool.toDescriptors([sendEmail])
|
|
222
|
+
expect(descriptor?.name).toBe("send_email")
|
|
223
|
+
expect(descriptor?.inputSchema).toMatchObject({
|
|
224
|
+
type: "object",
|
|
225
|
+
properties: { to: { type: "string" } },
|
|
226
|
+
required: ["to"],
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it("accepts an already-wrapped fromEffectSchema value unchanged", async () => {
|
|
231
|
+
const wrapped = Tool.make({
|
|
232
|
+
name: "send_email",
|
|
233
|
+
description: "Send an email to a single recipient.",
|
|
234
|
+
inputSchema: Tool.fromEffectSchema(Recipient),
|
|
235
|
+
run: ({ to }) => Effect.succeed(`queued: ${to}`),
|
|
236
|
+
})
|
|
237
|
+
expect(Tool.toDescriptors([wrapped])).toEqual(Tool.toDescriptors([sendEmail]))
|
|
238
|
+
const input = await Effect.runPromise(Tool.decodeArgs(wrapped, callWith('{"to":"x@y.z"}')))
|
|
239
|
+
expectTypeOf(input).toEqualTypeOf<{ readonly to: string }>()
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it("signal: decode-only kinds accept an Effect Schema directly", async () => {
|
|
243
|
+
const escalate = Tool.signal({
|
|
244
|
+
name: "escalate",
|
|
245
|
+
description: "Escalate to a human.",
|
|
246
|
+
inputSchema: Schema.Struct({ reason: Schema.String }),
|
|
247
|
+
})
|
|
248
|
+
const input = await Effect.runPromise(
|
|
249
|
+
Tool.decodeArgs(escalate, {
|
|
250
|
+
type: "function_call",
|
|
251
|
+
call_id: "call_2",
|
|
252
|
+
name: "escalate",
|
|
253
|
+
arguments: '{"reason":"stuck"}',
|
|
254
|
+
}),
|
|
255
|
+
)
|
|
256
|
+
expect(input).toEqual({ reason: "stuck" })
|
|
257
|
+
expectTypeOf(input).toEqualTypeOf<{ readonly reason: string }>()
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
describe("provider-hosted tools", () => {
|
|
262
|
+
const localTool = Tool.make({
|
|
263
|
+
name: "send_email",
|
|
264
|
+
description: "Send an email.",
|
|
265
|
+
inputSchema: Tool.fromEffectSchema(Schema.Struct({ to: Schema.String })),
|
|
266
|
+
run: ({ to }) => Effect.succeed(`sent: ${to}`),
|
|
267
|
+
})
|
|
268
|
+
const signalTool = Tool.signal({
|
|
269
|
+
name: "escalate",
|
|
270
|
+
description: "Escalate to a human.",
|
|
271
|
+
inputSchema: Schema.Struct({ reason: Schema.String }),
|
|
272
|
+
})
|
|
273
|
+
const hostedTool = Tool.provider({
|
|
274
|
+
name: "web_search",
|
|
275
|
+
description: "Search the web.",
|
|
276
|
+
inputSchema: Tool.noInput,
|
|
277
|
+
provider: "gemini",
|
|
278
|
+
config: { kind: "google_search" },
|
|
279
|
+
})
|
|
280
|
+
const toolkit = { send_email: localTool, escalate: signalTool, web_search: hostedTool }
|
|
281
|
+
|
|
282
|
+
it("descriptorsOf excludes provider-hosted tools", () => {
|
|
283
|
+
const descriptors = Tool.descriptorsOf(toolkit)
|
|
284
|
+
expect(descriptors.map((d) => d.name)).toEqual(["send_email", "escalate"])
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it("toDescriptors drops a ProviderTool from a mixed array", () => {
|
|
288
|
+
expect(Tool.toDescriptors([localTool, hostedTool]).map((d) => d.name)).toEqual(["send_email"])
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it("providerToolsOf returns only the provider-hosted tools", () => {
|
|
292
|
+
const provider = Tool.providerToolsOf(toolkit)
|
|
293
|
+
expect(provider).toHaveLength(1)
|
|
294
|
+
expect(provider[0]?.name).toBe("web_search")
|
|
295
|
+
expect(provider[0]?.provider).toBe("gemini")
|
|
296
|
+
expect(provider[0]?.config).toEqual({ kind: "google_search" })
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it("providerToolsOf on an absent toolkit is empty", () => {
|
|
300
|
+
expect(Tool.providerToolsOf(undefined)).toEqual([])
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it("isProviderTool narrows the tool kind", () => {
|
|
304
|
+
expect(Tool.isProviderTool(hostedTool)).toBe(true)
|
|
305
|
+
expect(Tool.isProviderTool(localTool)).toBe(false)
|
|
306
|
+
expect(Tool.isProviderTool(signalTool)).toBe(false)
|
|
307
|
+
})
|
|
308
|
+
})
|