@effect-uai/core 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{AiError-CAX_48RU.d.mts → AiError-CrdR1EQi.d.mts} +2 -2
- package/dist/{AiError-CAX_48RU.d.mts.map → AiError-CrdR1EQi.d.mts.map} +1 -1
- package/dist/{Audio-DPlTsNdz.d.mts → Audio-DDSKCkSe.d.mts} +2 -2
- package/dist/{Audio-DPlTsNdz.d.mts.map → Audio-DDSKCkSe.d.mts.map} +1 -1
- package/dist/{Image-HNmMpMTh.d.mts → Image-C_Zjpwhc.d.mts} +2 -2
- package/dist/{Image-HNmMpMTh.d.mts.map → Image-C_Zjpwhc.d.mts.map} +1 -1
- package/dist/{Items-D8UaXQx_.d.mts → Items-CgKD6Npw.d.mts} +5 -5
- package/dist/{Items-D8UaXQx_.d.mts.map → Items-CgKD6Npw.d.mts.map} +1 -1
- package/dist/Loop-B5_cqumi.d.mts +173 -0
- package/dist/Loop-B5_cqumi.d.mts.map +1 -0
- package/dist/{Media-D_CpcM1Z.d.mts → Media-DOLyaygI.d.mts} +1 -1
- package/dist/{Media-D_CpcM1Z.d.mts.map → Media-DOLyaygI.d.mts.map} +1 -1
- package/dist/{SandboxError-BvKypkJm.d.mts → SandboxError-CIrVDOMV.d.mts} +2 -2
- package/dist/{SandboxError-BvKypkJm.d.mts.map → SandboxError-CIrVDOMV.d.mts.map} +1 -1
- package/dist/{StructuredFormat-BbN4dosH.d.mts → StructuredFormat-CMJFD3sn.d.mts} +2 -2
- package/dist/{StructuredFormat-BbN4dosH.d.mts.map → StructuredFormat-CMJFD3sn.d.mts.map} +1 -1
- package/dist/Tool-0XUIT2A2.d.mts +232 -0
- package/dist/Tool-0XUIT2A2.d.mts.map +1 -0
- package/dist/ToolEvent-DYrAUJGs.d.mts +151 -0
- package/dist/ToolEvent-DYrAUJGs.d.mts.map +1 -0
- package/dist/ToolResult-BZd-cEWW.d.mts +151 -0
- package/dist/ToolResult-BZd-cEWW.d.mts.map +1 -0
- package/dist/Toolkit-BKC8b8Xo.d.mts +155 -0
- package/dist/Toolkit-BKC8b8Xo.d.mts.map +1 -0
- package/dist/{dist-DV5ISja1.mjs → dist-B04dlFHg.mjs} +3 -3
- package/dist/{dist-DV5ISja1.mjs.map → dist-B04dlFHg.mjs.map} +1 -1
- package/dist/dist-DWgRbDN3.mjs +140 -0
- package/dist/dist-DWgRbDN3.mjs.map +1 -0
- package/dist/domain/AiError.d.mts +1 -1
- package/dist/domain/Audio.d.mts +1 -1
- package/dist/domain/Image.d.mts +1 -1
- package/dist/domain/Items.d.mts +1 -1
- package/dist/domain/Media.d.mts +1 -1
- package/dist/domain/Music.d.mts +1 -1
- package/dist/domain/Turn.d.mts +4 -4
- package/dist/domain/Turn.test.mjs +1 -1
- package/dist/embedding-model/Embedding.d.mts +1 -1
- package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
- package/dist/embedding-model/EmbeddingModel.test.mjs +1 -1
- package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -1
- package/dist/index.d.mts +16 -13
- package/dist/index.mjs +5 -2
- package/dist/language-model/LanguageModel.d.mts +10 -5
- package/dist/language-model/LanguageModel.d.mts.map +1 -1
- package/dist/language-model/LanguageModel.mjs.map +1 -1
- package/dist/language-model/LanguageModel.test.mjs +1 -1
- package/dist/loop/Loop.d.mts +2 -141
- package/dist/loop/Loop.mjs +16 -20
- package/dist/loop/Loop.mjs.map +1 -1
- package/dist/loop/Loop.test.mjs +18 -1
- package/dist/loop/Loop.test.mjs.map +1 -1
- package/dist/{magic-string.es-BgIV5Mu3.mjs → magic-string.es-Dk-MMA0F.mjs} +1 -1
- package/dist/{magic-string.es-BgIV5Mu3.mjs.map → magic-string.es-Dk-MMA0F.mjs.map} +1 -1
- package/dist/music-generator/MusicGenerator.d.mts +2 -2
- package/dist/music-generator/MusicGenerator.test.mjs +1 -1
- package/dist/observability/Metrics.d.mts +116 -28
- package/dist/observability/Metrics.d.mts.map +1 -1
- package/dist/observability/Metrics.mjs +250 -38
- package/dist/observability/Metrics.mjs.map +1 -1
- package/dist/observability/Metrics.test.d.mts +1 -0
- package/dist/observability/Metrics.test.mjs +187 -0
- package/dist/observability/Metrics.test.mjs.map +1 -0
- package/dist/observability/Telemetry.d.mts +37 -0
- package/dist/observability/Telemetry.d.mts.map +1 -0
- package/dist/observability/Telemetry.mjs +57 -0
- package/dist/observability/Telemetry.mjs.map +1 -0
- package/dist/observability/Telemetry.test.d.mts +1 -0
- package/dist/observability/Telemetry.test.mjs +92 -0
- package/dist/observability/Telemetry.test.mjs.map +1 -0
- package/dist/retry/Retry.d.mts +2 -2
- package/dist/retry/Retry.test.mjs +1 -1
- package/dist/sandbox/Sandbox.d.mts +1 -1
- package/dist/sandbox/SandboxError.d.mts +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +2 -2
- package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +1 -1
- package/dist/streaming/JSONL.d.mts +1 -1
- package/dist/streaming/JSONL.test.mjs +1 -1
- package/dist/streaming/SSE.test.mjs +1 -1
- package/dist/structured-format/StructuredFormat.d.mts +1 -1
- package/dist/structured-format/StructuredFormat.mjs.map +1 -1
- package/dist/structured-format/StructuredFormat.test.mjs +1 -1
- package/dist/testing/MockMusicGenerator.d.mts +1 -1
- package/dist/testing/MockProvider.d.mts +1 -1
- package/dist/testing/MockSpeechSynthesizer.d.mts +1 -1
- package/dist/tool/Approval.d.mts +3 -3
- package/dist/tool/Approval.test.mjs +35 -34
- package/dist/tool/Approval.test.mjs.map +1 -1
- package/dist/tool/HistoryCheck.d.mts +2 -2
- package/dist/tool/Tool.d.mts +2 -2
- package/dist/tool/Tool.mjs +112 -22
- package/dist/tool/Tool.mjs.map +1 -1
- package/dist/tool/Tool.test.mjs +41 -3
- package/dist/tool/Tool.test.mjs.map +1 -1
- package/dist/tool/ToolEvent.d.mts +2 -151
- package/dist/tool/ToolResult.d.mts +2 -138
- package/dist/tool/ToolResult.mjs +18 -3
- package/dist/tool/ToolResult.mjs.map +1 -1
- package/dist/tool/Toolkit.d.mts +2 -73
- package/dist/tool/Toolkit.mjs +106 -28
- package/dist/tool/Toolkit.mjs.map +1 -1
- package/dist/tool/Toolkit.test.mjs +113 -7
- package/dist/tool/Toolkit.test.mjs.map +1 -1
- package/dist/transcriber/Transcriber.d.mts +2 -2
- package/dist/transcriber/Transcriber.test.mjs +1 -1
- package/dist/web-search/WebSearch.d.mts +119 -0
- package/dist/web-search/WebSearch.d.mts.map +1 -0
- package/dist/web-search/WebSearch.mjs +42 -0
- package/dist/web-search/WebSearch.mjs.map +1 -0
- package/dist/web-search/WebSearchTool.d.mts +58 -0
- package/dist/web-search/WebSearchTool.d.mts.map +1 -0
- package/dist/web-search/WebSearchTool.mjs +69 -0
- package/dist/web-search/WebSearchTool.mjs.map +1 -0
- package/package.json +9 -1
- package/src/embedding-model/EmbeddingModel.test.ts +4 -0
- package/src/index.ts +3 -0
- package/src/language-model/LanguageModel.ts +7 -2
- package/src/loop/Loop.test.ts +22 -0
- package/src/loop/Loop.ts +27 -4
- package/src/observability/Metrics.test.ts +266 -0
- package/src/observability/Metrics.ts +505 -65
- package/src/observability/Telemetry.test.ts +123 -0
- package/src/observability/Telemetry.ts +96 -0
- package/src/structured-format/StructuredFormat.ts +4 -0
- package/src/tool/Approval.test.ts +22 -21
- package/src/tool/Tool.test.ts +55 -2
- package/src/tool/Tool.ts +221 -55
- package/src/tool/ToolResult.ts +22 -1
- package/src/tool/Toolkit.test.ts +168 -7
- package/src/tool/Toolkit.ts +336 -99
- package/src/web-search/WebSearch.ts +150 -0
- package/src/web-search/WebSearchTool.ts +106 -0
- package/dist/Tool-CiFXYjmR.d.mts +0 -102
- package/dist/Tool-CiFXYjmR.d.mts.map +0 -1
- package/dist/loop/Loop.d.mts.map +0 -1
- package/dist/tool/ToolEvent.d.mts.map +0 -1
- package/dist/tool/ToolResult.d.mts.map +0 -1
- package/dist/tool/Toolkit.d.mts.map +0 -1
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, it } from "@effect/vitest"
|
|
2
|
+
import { Array as Arr, Duration, Effect, Layer, Metric, Option, Ref, Stream } from "effect"
|
|
3
|
+
import { type HttpBody, HttpClient, HttpClientResponse } from "effect/unstable/http"
|
|
4
|
+
import { expect } from "vitest"
|
|
5
|
+
import { type Measurement, makeEvent } from "./Metrics.js"
|
|
6
|
+
import { layerOtlp, record } from "./Telemetry.js"
|
|
7
|
+
|
|
8
|
+
const event = (measurements: ReadonlyArray<Measurement>) =>
|
|
9
|
+
makeEvent({ _tag: "Test", turnIndex: 0, measurements })
|
|
10
|
+
|
|
11
|
+
// Each test gets a fresh registry so instruments do not leak across tests.
|
|
12
|
+
const isolated = <A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
|
13
|
+
Effect.provideService(self, Metric.MetricRegistry, new Map())
|
|
14
|
+
|
|
15
|
+
const findById = (snap: ReadonlyArray<Metric.Metric.Snapshot>, id: string) =>
|
|
16
|
+
Option.getOrThrow(Arr.findFirst(snap, (s) => s.id === id))
|
|
17
|
+
|
|
18
|
+
describe("record", () => {
|
|
19
|
+
it.effect("accumulates a counter measurement across events", () =>
|
|
20
|
+
isolated(
|
|
21
|
+
Effect.gen(function* () {
|
|
22
|
+
const ev = event([{ name: "rec_counter", kind: "counter", value: 5 }])
|
|
23
|
+
yield* Stream.make(ev, ev).pipe(record(), Stream.runDrain)
|
|
24
|
+
const snap = yield* Metric.snapshot
|
|
25
|
+
const c = findById(snap, "rec_counter")
|
|
26
|
+
expect(c.type).toBe("Counter")
|
|
27
|
+
expect((c.state as { readonly count: number }).count).toBe(10)
|
|
28
|
+
}),
|
|
29
|
+
),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
it.effect("records a timer measurement as a histogram observation", () =>
|
|
33
|
+
isolated(
|
|
34
|
+
Effect.gen(function* () {
|
|
35
|
+
const ev = event([{ name: "rec_timer", kind: "timer", value: Duration.millis(100) }])
|
|
36
|
+
yield* Stream.make(ev).pipe(record(), Stream.runDrain)
|
|
37
|
+
const snap = yield* Metric.snapshot
|
|
38
|
+
const h = findById(snap, "rec_timer")
|
|
39
|
+
expect(h.type).toBe("Histogram")
|
|
40
|
+
expect((h.state as { readonly count: number }).count).toBe(1)
|
|
41
|
+
}),
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
it.effect("applies base attributes to every recorded metric", () =>
|
|
46
|
+
isolated(
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
const ev = event([{ name: "rec_attr", kind: "counter", value: 1 }])
|
|
49
|
+
yield* Stream.make(ev).pipe(
|
|
50
|
+
record({ attributes: { model: "test-model" } }),
|
|
51
|
+
Stream.runDrain,
|
|
52
|
+
)
|
|
53
|
+
const snap = yield* Metric.snapshot
|
|
54
|
+
const c = findById(snap, "rec_attr")
|
|
55
|
+
expect(c.attributes).toMatchObject({ model: "test-model" })
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
it.effect("records a custom metric with a novel name, unchanged recorder", () =>
|
|
61
|
+
isolated(
|
|
62
|
+
Effect.gen(function* () {
|
|
63
|
+
// A user-defined event the recorder has never seen: still exported.
|
|
64
|
+
const custom = makeEvent({
|
|
65
|
+
_tag: "ToolLatency",
|
|
66
|
+
turnIndex: 0,
|
|
67
|
+
measurements: [{ name: "myapp_tool_latency_total", kind: "counter", value: 3 }],
|
|
68
|
+
})
|
|
69
|
+
yield* Stream.make(custom).pipe(record(), Stream.runDrain)
|
|
70
|
+
const snap = yield* Metric.snapshot
|
|
71
|
+
const c = findById(snap, "myapp_tool_latency_total")
|
|
72
|
+
expect((c.state as { readonly count: number }).count).toBe(3)
|
|
73
|
+
}),
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
it.effect("passes non-metric elements through untouched", () =>
|
|
78
|
+
isolated(
|
|
79
|
+
Effect.gen(function* () {
|
|
80
|
+
const out = yield* Stream.make("a", "b", "c").pipe(record(), Stream.runCollect)
|
|
81
|
+
expect(out).toEqual(["a", "b", "c"])
|
|
82
|
+
}),
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const bodyToString = (body: HttpBody.HttpBody): string =>
|
|
88
|
+
body._tag === "Uint8Array" ? new TextDecoder().decode(body.body) : ""
|
|
89
|
+
|
|
90
|
+
describe("layerOtlp", () => {
|
|
91
|
+
it.effect("exports recorded metrics over OTLP via a mock HttpClient", () =>
|
|
92
|
+
isolated(
|
|
93
|
+
Effect.gen(function* () {
|
|
94
|
+
const bodies = yield* Ref.make<ReadonlyArray<string>>([])
|
|
95
|
+
const mockClient = HttpClient.make((request) =>
|
|
96
|
+
Ref.update(bodies, (b) => [...b, bodyToString(request.body)]).pipe(
|
|
97
|
+
Effect.as(HttpClientResponse.fromWeb(request, new Response("{}", { status: 200 }))),
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
const clientLayer = Layer.succeed(HttpClient.HttpClient, mockClient)
|
|
101
|
+
const ev = event([{ name: "otlp_export_counter", kind: "counter", value: 3 }])
|
|
102
|
+
|
|
103
|
+
// Provide the OTLP layer scoped to the recording program. The exporter
|
|
104
|
+
// flushes on scope close (interval is long, so we rely on the flush).
|
|
105
|
+
yield* Stream.make(ev).pipe(
|
|
106
|
+
record(),
|
|
107
|
+
Stream.runDrain,
|
|
108
|
+
Effect.provide(
|
|
109
|
+
layerOtlp({
|
|
110
|
+
url: "http://localhost:4318/v1/metrics",
|
|
111
|
+
resource: { serviceName: "test-service" },
|
|
112
|
+
exportInterval: "10 minutes",
|
|
113
|
+
}).pipe(Layer.provide(clientLayer)),
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
const captured = yield* Ref.get(bodies)
|
|
118
|
+
expect(captured.length).toBeGreaterThanOrEqual(1)
|
|
119
|
+
expect(captured.join("\n")).toContain("otlp_export_counter")
|
|
120
|
+
}),
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Duration, Effect, Layer, Match, Metric, Stream } from "effect"
|
|
2
|
+
import type { HttpClient } from "effect/unstable/http"
|
|
3
|
+
import { OtlpMetrics, OtlpSerialization } from "effect/unstable/observability"
|
|
4
|
+
import { isMetricEvent, type Measurement, type MetricEvent } from "./Metrics.js"
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// The agnostic recorder
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Histogram bucket boundaries used for every histogram measurement (the only
|
|
12
|
+
* built-in one is throughput). Custom histogram metrics share these; a metric
|
|
13
|
+
* that needs bespoke boundaries can update its own instrument directly instead
|
|
14
|
+
* of routing through `record`.
|
|
15
|
+
*/
|
|
16
|
+
const histogramBoundaries = Metric.exponentialBoundaries({ start: 1, factor: 2, count: 16 })
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build the effect `Metric` instrument a measurement maps to. Dispatches on
|
|
20
|
+
* `kind`, never on the event `_tag`, so a new built-in or custom metric needs
|
|
21
|
+
* no change here. Instruments dedupe by name in the global registry, so a fresh
|
|
22
|
+
* instance per call accumulates onto the same series (no cache needed).
|
|
23
|
+
*/
|
|
24
|
+
const buildInstrument = (m: Measurement): Metric.Metric<any, any> =>
|
|
25
|
+
Match.value(m.kind).pipe(
|
|
26
|
+
Match.when("counter", () => Metric.counter(m.name, { incremental: true })),
|
|
27
|
+
Match.when("histogram", () => Metric.histogram(m.name, { boundaries: histogramBoundaries })),
|
|
28
|
+
Match.when("timer", () => Metric.timer(m.name)),
|
|
29
|
+
Match.when("gauge", () => Metric.gauge(m.name)),
|
|
30
|
+
Match.exhaustive,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const mergeAttributes = (
|
|
34
|
+
base: Readonly<Record<string, string>> | undefined,
|
|
35
|
+
extra: Readonly<Record<string, string>> | undefined,
|
|
36
|
+
): Readonly<Record<string, string>> | undefined =>
|
|
37
|
+
base === undefined ? extra : extra === undefined ? base : { ...base, ...extra }
|
|
38
|
+
|
|
39
|
+
const recordEvent = (
|
|
40
|
+
ev: MetricEvent,
|
|
41
|
+
baseAttributes: Readonly<Record<string, string>> | undefined,
|
|
42
|
+
): Effect.Effect<void> =>
|
|
43
|
+
Effect.forEach(
|
|
44
|
+
ev.measurements,
|
|
45
|
+
(m) => {
|
|
46
|
+
const attributes = mergeAttributes(baseAttributes, m.attributes)
|
|
47
|
+
const base = buildInstrument(m)
|
|
48
|
+
const instrument = attributes === undefined ? base : Metric.withAttributes(base, attributes)
|
|
49
|
+
return Metric.update(instrument, m.value)
|
|
50
|
+
},
|
|
51
|
+
{ discard: true },
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
export type RecordOptions = {
|
|
55
|
+
/** Attributes applied to every recorded metric, e.g. `{ model, provider }`. */
|
|
56
|
+
readonly attributes?: Readonly<Record<string, string>>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Record every measurement on every metric event into effect `Metric`
|
|
61
|
+
* instruments, then pass events through unchanged. Matches events structurally
|
|
62
|
+
* (`isMetricEvent`), so it records built-in and custom metrics alike, and
|
|
63
|
+
* dispatches on `measurement.kind`, never on the event tag. Sits mid-pipeline
|
|
64
|
+
* before the transport. Provide an OTLP (or Prometheus) layer to export the
|
|
65
|
+
* instruments it populates.
|
|
66
|
+
*/
|
|
67
|
+
export const record =
|
|
68
|
+
(options?: RecordOptions) =>
|
|
69
|
+
<A, E, R>(self: Stream.Stream<A, E, R>): Stream.Stream<A, E, R> =>
|
|
70
|
+
self.pipe(
|
|
71
|
+
Stream.tap((ev) => (isMetricEvent(ev) ? recordEvent(ev, options?.attributes) : Effect.void)),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// OTLP wiring
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
export type LayerOtlpOptions = {
|
|
79
|
+
readonly url: string
|
|
80
|
+
readonly resource?: {
|
|
81
|
+
readonly serviceName?: string
|
|
82
|
+
readonly serviceVersion?: string
|
|
83
|
+
}
|
|
84
|
+
readonly exportInterval?: Duration.Input
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Convenience layer that exports recorded metrics over OTLP. Provides the JSON
|
|
89
|
+
* serializer and leaves `HttpClient` as a requirement, so the platform's client
|
|
90
|
+
* (FetchHttpClient, NodeHttpClient, ...) is provided at the edge. This keeps
|
|
91
|
+
* the library platform-independent.
|
|
92
|
+
*/
|
|
93
|
+
export const layerOtlp = (
|
|
94
|
+
options: LayerOtlpOptions,
|
|
95
|
+
): Layer.Layer<never, never, HttpClient.HttpClient> =>
|
|
96
|
+
OtlpMetrics.layer(options).pipe(Layer.provide(OtlpSerialization.layerJson))
|
|
@@ -142,6 +142,10 @@ export const parseJson =
|
|
|
142
142
|
(raw: string): Effect.Effect<A, JsonParseError | StructuredDecodeError> =>
|
|
143
143
|
pipe(
|
|
144
144
|
Effect.try({
|
|
145
|
+
// Validation runs through Standard Schema (Zod / Valibot / Effect), not
|
|
146
|
+
// Effect Schema, so its JSON codecs don't apply here; and JSON.parse is
|
|
147
|
+
// a separate step on purpose, to surface JsonParseError distinctly.
|
|
148
|
+
// @effect-diagnostics-next-line effect/preferSchemaOverJson:off
|
|
145
149
|
try: () => JSON.parse(raw),
|
|
146
150
|
catch: (cause) => new JsonParseError({ raw, cause }),
|
|
147
151
|
}),
|
|
@@ -16,8 +16,8 @@ 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
18
|
import { type ApprovalMapEntry, type ApprovalDecision, fromMap, fromQueue } from "./Approval.js"
|
|
19
|
-
import { fromEffectSchema, make as makeTool
|
|
20
|
-
import { run } from "./Toolkit.js"
|
|
19
|
+
import { fromEffectSchema, make as makeTool } from "./Tool.js"
|
|
20
|
+
import { make as makeToolkit, run } from "./Toolkit.js"
|
|
21
21
|
import { ToolEvent, isApprovalRequested, isProgress, isOutput } from "./ToolEvent.js"
|
|
22
22
|
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
@@ -27,34 +27,35 @@ import { ToolEvent, isApprovalRequested, isProgress, isOutput } from "./ToolEven
|
|
|
27
27
|
// - delete_database : non-streaming, requires approval
|
|
28
28
|
// ---------------------------------------------------------------------------
|
|
29
29
|
|
|
30
|
-
const webSearch =
|
|
30
|
+
const webSearch = makeTool({
|
|
31
31
|
name: "web_search",
|
|
32
32
|
description: "search",
|
|
33
33
|
inputSchema: fromEffectSchema(Schema.Struct({ query: Schema.String })),
|
|
34
|
-
run: ({ query }) =>
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
run: ({ query }, emit) =>
|
|
35
|
+
Effect.gen(function* () {
|
|
36
|
+
const hits = [
|
|
37
|
+
{ url: "a", title: `${query} 1` },
|
|
38
|
+
{ url: "b", title: `${query} 2` },
|
|
39
|
+
{ url: "c", title: `${query} 3` },
|
|
40
|
+
]
|
|
41
|
+
yield* Stream.runForEach(Stream.fromIterable(hits), emit)
|
|
42
|
+
return { count: hits.length }
|
|
43
|
+
}),
|
|
41
44
|
})
|
|
42
45
|
|
|
43
|
-
const bulkEmail =
|
|
46
|
+
const bulkEmail = makeTool({
|
|
44
47
|
name: "bulk_email",
|
|
45
48
|
description: "send",
|
|
46
49
|
inputSchema: fromEffectSchema(
|
|
47
50
|
Schema.Struct({ recipients: Schema.Array(Schema.String), subject: Schema.String }),
|
|
48
51
|
),
|
|
49
|
-
run: ({ recipients }) =>
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
type: "progress" as const,
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
),
|
|
57
|
-
finalize: (events) => ({ status: "sent" as const, delivered: events.length }),
|
|
52
|
+
run: ({ recipients }, emit) =>
|
|
53
|
+
Effect.gen(function* () {
|
|
54
|
+
yield* Effect.forEach(recipients, (_, i) =>
|
|
55
|
+
emit({ type: "progress" as const, sent: i + 1, total: recipients.length }),
|
|
56
|
+
)
|
|
57
|
+
return { status: "sent" as const, delivered: recipients.length }
|
|
58
|
+
}),
|
|
58
59
|
})
|
|
59
60
|
|
|
60
61
|
const deleteDatabase = makeTool({
|
|
@@ -64,7 +65,7 @@ const deleteDatabase = makeTool({
|
|
|
64
65
|
run: ({ name }) => Effect.succeed({ status: "dropped", name }),
|
|
65
66
|
})
|
|
66
67
|
|
|
67
|
-
const allTools =
|
|
68
|
+
const allTools = makeToolkit(webSearch, bulkEmail, deleteDatabase)
|
|
68
69
|
const SENSITIVE = new Set(["bulk_email", "delete_database"])
|
|
69
70
|
const isSensitive = (call: Items.ToolCall) => SENSITIVE.has(call.name)
|
|
70
71
|
|
package/src/tool/Tool.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
3
|
import { describe, expect, expectTypeOf, it } from "vitest"
|
|
4
|
+
import type * as Items from "../domain/Items.js"
|
|
4
5
|
import * as Tool from "./Tool.js"
|
|
5
6
|
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
@@ -81,7 +82,7 @@ describe("Tool.fromStandardSchema", () => {
|
|
|
81
82
|
|
|
82
83
|
// `run`'s parameter is typed as { to: string } via the schema's Output —
|
|
83
84
|
// this property access compiles without annotation.
|
|
84
|
-
const result = await Effect.runPromise(sendEmail.run({ to: "x@y.z" }))
|
|
85
|
+
const result = await Effect.runPromise(sendEmail.run({ to: "x@y.z" }, () => Effect.void))
|
|
85
86
|
expect(result).toBe("queued: x@y.z")
|
|
86
87
|
})
|
|
87
88
|
|
|
@@ -99,7 +100,59 @@ describe("Tool.fromStandardSchema", () => {
|
|
|
99
100
|
run: (input) => Effect.succeed(input),
|
|
100
101
|
})
|
|
101
102
|
|
|
102
|
-
type InputOf<T> = T extends Tool.Tool<string, infer I,
|
|
103
|
+
type InputOf<T> = T extends Tool.Tool<string, infer I, any, any, any> ? I : never
|
|
103
104
|
expectTypeOf<InputOf<typeof tool>>().toEqualTypeOf<EmailRecipient>()
|
|
104
105
|
})
|
|
105
106
|
})
|
|
107
|
+
|
|
108
|
+
describe("Tool.withRun", () => {
|
|
109
|
+
const sendEmail = Tool.make({
|
|
110
|
+
name: "send_email",
|
|
111
|
+
description: "Send an email to a single recipient.",
|
|
112
|
+
inputSchema: Tool.fromStandardSchema(emailRecipientSchema),
|
|
113
|
+
run: ({ to }) => Effect.succeed(`sent: ${to}`),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("swaps run while keeping the model-facing descriptor identical", async () => {
|
|
117
|
+
const dryRun = Tool.withRun(sendEmail, ({ to }) => Effect.succeed(`dry-run: ${to}`))
|
|
118
|
+
|
|
119
|
+
// Definition (name/description/schema) is byte-identical -> same descriptor.
|
|
120
|
+
expect(Tool.toDescriptors([dryRun])).toEqual(Tool.toDescriptors([sendEmail]))
|
|
121
|
+
// input is typed from the original tool, no annotation needed.
|
|
122
|
+
const out = await Effect.runPromise(dryRun.run({ to: "x@y.z" }, () => Effect.void))
|
|
123
|
+
expect(out).toBe("dry-run: x@y.z")
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe("Tool.decodeArgs", () => {
|
|
128
|
+
const sendEmail = Tool.make({
|
|
129
|
+
name: "send_email",
|
|
130
|
+
description: "Send an email to a single recipient.",
|
|
131
|
+
inputSchema: Tool.fromStandardSchema(emailRecipientSchema),
|
|
132
|
+
run: ({ to }) => Effect.succeed(`queued: ${to}`),
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const callWith = (args: string): Items.ToolCall => ({
|
|
136
|
+
type: "function_call",
|
|
137
|
+
call_id: "call_1",
|
|
138
|
+
name: "send_email",
|
|
139
|
+
arguments: args,
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("decodes valid JSON arguments to the typed input", async () => {
|
|
143
|
+
const input = await Effect.runPromise(Tool.decodeArgs(sendEmail, callWith('{"to":"x@y.z"}')))
|
|
144
|
+
expect(input).toEqual({ to: "x@y.z" })
|
|
145
|
+
// The return type is the tool's Input, no annotation needed.
|
|
146
|
+
expectTypeOf(input).toEqualTypeOf<EmailRecipient>()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("fails with ToolError when the arguments aren't valid JSON", async () => {
|
|
150
|
+
const exit = await Effect.runPromiseExit(Tool.decodeArgs(sendEmail, callWith("not json")))
|
|
151
|
+
expect(exit._tag).toBe("Failure")
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it("fails with ToolError when the arguments don't satisfy the schema", async () => {
|
|
155
|
+
const exit = await Effect.runPromiseExit(Tool.decodeArgs(sendEmail, callWith('{"to":42}')))
|
|
156
|
+
expect(exit._tag).toBe("Failure")
|
|
157
|
+
})
|
|
158
|
+
})
|