@effect-uai/core 0.8.0 → 0.10.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-Bf1pq4BB.d.mts} +9 -4
- package/dist/{AiError-CAX_48RU.d.mts.map → AiError-Bf1pq4BB.d.mts.map} +1 -1
- package/dist/{Audio-DPlTsNdz.d.mts → Audio-CR7CSljJ.d.mts} +1 -1
- package/dist/{Audio-DPlTsNdz.d.mts.map → Audio-CR7CSljJ.d.mts.map} +1 -1
- package/dist/{Image-HNmMpMTh.d.mts → Image-BZmKfIdq.d.mts} +1 -1
- package/dist/{Image-HNmMpMTh.d.mts.map → Image-BZmKfIdq.d.mts.map} +1 -1
- package/dist/{Items-7ljaVxH_.d.mts → Items-BnIduEV2.d.mts} +7 -7
- package/dist/{Items-7ljaVxH_.d.mts.map → Items-BnIduEV2.d.mts.map} +1 -1
- package/dist/Loop-D8nWpowc.d.mts +173 -0
- package/dist/Loop-D8nWpowc.d.mts.map +1 -0
- 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-DHaUJBdd.d.mts} +2 -2
- package/dist/{StructuredFormat-BbN4dosH.d.mts.map → StructuredFormat-DHaUJBdd.d.mts.map} +1 -1
- package/dist/Tool-2lC_XMth.d.mts +284 -0
- package/dist/Tool-2lC_XMth.d.mts.map +1 -0
- package/dist/ToolEvent-cWZ2BnbS.d.mts +151 -0
- package/dist/ToolEvent-cWZ2BnbS.d.mts.map +1 -0
- package/dist/ToolResult-CL4hK_9z.d.mts +157 -0
- package/dist/ToolResult-CL4hK_9z.d.mts.map +1 -0
- package/dist/Toolkit-Dr9l_hNy.d.mts +196 -0
- package/dist/Toolkit-Dr9l_hNy.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 +58 -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-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 +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/Image.d.mts +1 -1
- package/dist/domain/Items.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 +19 -13
- package/dist/index.mjs +15 -9
- 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 +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 +80 -40
- 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 +190 -36
- 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/ToolEvent.mjs +5 -0
- package/dist/tool/ToolEvent.mjs.map +1 -1
- package/dist/tool/ToolResult.d.mts +2 -138
- package/dist/tool/ToolResult.mjs +32 -8
- package/dist/tool/ToolResult.mjs.map +1 -1
- package/dist/tool/Toolkit.d.mts +2 -73
- package/dist/tool/Toolkit.mjs +153 -35
- package/dist/tool/Toolkit.mjs.map +1 -1
- package/dist/tool/Toolkit.test.mjs +271 -11
- 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-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/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 +21 -1
- 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/embedding-model/EmbeddingModel.test.ts +4 -0
- package/src/index.ts +6 -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 +77 -26
- package/src/tool/Approval.ts +72 -27
- package/src/tool/Tool.test.ts +55 -2
- package/src/tool/Tool.ts +335 -70
- package/src/tool/ToolEvent.ts +5 -0
- package/src/tool/ToolResult.ts +36 -9
- package/src/tool/Toolkit.test.ts +339 -9
- package/src/tool/Toolkit.ts +470 -120
- package/src/web-read/WebRead.ts +70 -0
- package/src/web-read/WebReadTool.ts +96 -0
- package/src/web-search/WebSearchTool.ts +11 -5
- 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
|
@@ -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
|
}),
|
|
@@ -15,9 +15,15 @@ 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 {
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
import {
|
|
19
|
+
type ApprovalMapEntry,
|
|
20
|
+
type ApprovalDecision,
|
|
21
|
+
type Verdict,
|
|
22
|
+
fromMap,
|
|
23
|
+
fromQueue,
|
|
24
|
+
} from "./Approval.js"
|
|
25
|
+
import { fromEffectSchema, make as makeTool } from "./Tool.js"
|
|
26
|
+
import { make as makeToolkit, run } from "./Toolkit.js"
|
|
21
27
|
import { ToolEvent, isApprovalRequested, isProgress, isOutput } from "./ToolEvent.js"
|
|
22
28
|
|
|
23
29
|
// ---------------------------------------------------------------------------
|
|
@@ -27,34 +33,35 @@ import { ToolEvent, isApprovalRequested, isProgress, isOutput } from "./ToolEven
|
|
|
27
33
|
// - delete_database : non-streaming, requires approval
|
|
28
34
|
// ---------------------------------------------------------------------------
|
|
29
35
|
|
|
30
|
-
const webSearch =
|
|
36
|
+
const webSearch = makeTool({
|
|
31
37
|
name: "web_search",
|
|
32
38
|
description: "search",
|
|
33
39
|
inputSchema: fromEffectSchema(Schema.Struct({ query: Schema.String })),
|
|
34
|
-
run: ({ query }) =>
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
run: ({ query }, emit) =>
|
|
41
|
+
Effect.gen(function* () {
|
|
42
|
+
const hits = [
|
|
43
|
+
{ url: "a", title: `${query} 1` },
|
|
44
|
+
{ url: "b", title: `${query} 2` },
|
|
45
|
+
{ url: "c", title: `${query} 3` },
|
|
46
|
+
]
|
|
47
|
+
yield* Stream.runForEach(Stream.fromIterable(hits), emit)
|
|
48
|
+
return { count: hits.length }
|
|
49
|
+
}),
|
|
41
50
|
})
|
|
42
51
|
|
|
43
|
-
const bulkEmail =
|
|
52
|
+
const bulkEmail = makeTool({
|
|
44
53
|
name: "bulk_email",
|
|
45
54
|
description: "send",
|
|
46
55
|
inputSchema: fromEffectSchema(
|
|
47
56
|
Schema.Struct({ recipients: Schema.Array(Schema.String), subject: Schema.String }),
|
|
48
57
|
),
|
|
49
|
-
run: ({ recipients }) =>
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
type: "progress" as const,
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
),
|
|
57
|
-
finalize: (events) => ({ status: "sent" as const, delivered: events.length }),
|
|
58
|
+
run: ({ recipients }, emit) =>
|
|
59
|
+
Effect.gen(function* () {
|
|
60
|
+
yield* Effect.forEach(recipients, (_, i) =>
|
|
61
|
+
emit({ type: "progress" as const, sent: i + 1, total: recipients.length }),
|
|
62
|
+
)
|
|
63
|
+
return { status: "sent" as const, delivered: recipients.length }
|
|
64
|
+
}),
|
|
58
65
|
})
|
|
59
66
|
|
|
60
67
|
const deleteDatabase = makeTool({
|
|
@@ -64,7 +71,7 @@ const deleteDatabase = makeTool({
|
|
|
64
71
|
run: ({ name }) => Effect.succeed({ status: "dropped", name }),
|
|
65
72
|
})
|
|
66
73
|
|
|
67
|
-
const allTools =
|
|
74
|
+
const allTools = makeToolkit(webSearch, bulkEmail, deleteDatabase)
|
|
68
75
|
const SENSITIVE = new Set(["bulk_email", "delete_database"])
|
|
69
76
|
const isSensitive = (call: Items.ToolCall) => SENSITIVE.has(call.name)
|
|
70
77
|
|
|
@@ -248,6 +255,50 @@ describe("fromQueue + run", () => {
|
|
|
248
255
|
reason: "too risky",
|
|
249
256
|
})
|
|
250
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
|
+
})
|
|
251
302
|
})
|
|
252
303
|
|
|
253
304
|
// ---------------------------------------------------------------------------
|
|
@@ -312,7 +363,7 @@ describe("toToolCallOutput", () => {
|
|
|
312
363
|
expect(JSON.parse(out.output)).toEqual({ count: 3 })
|
|
313
364
|
})
|
|
314
365
|
|
|
315
|
-
it("
|
|
366
|
+
it("renders a Failure with reason as a nested error object", () => {
|
|
316
367
|
const r: ToolResult = {
|
|
317
368
|
_tag: "Failure",
|
|
318
369
|
call_id: "c2",
|
|
@@ -321,10 +372,10 @@ describe("toToolCallOutput", () => {
|
|
|
321
372
|
reason: "spam concern",
|
|
322
373
|
}
|
|
323
374
|
const out = toToolCallOutput(r)
|
|
324
|
-
expect(JSON.parse(out.output)).toEqual({ kind: "denied",
|
|
375
|
+
expect(JSON.parse(out.output)).toEqual({ error: { kind: "denied", message: "spam concern" } })
|
|
325
376
|
})
|
|
326
377
|
|
|
327
|
-
it("
|
|
378
|
+
it("renders a Failure without reason (omits message)", () => {
|
|
328
379
|
const r: ToolResult = {
|
|
329
380
|
_tag: "Failure",
|
|
330
381
|
call_id: "c3",
|
|
@@ -332,7 +383,7 @@ describe("toToolCallOutput", () => {
|
|
|
332
383
|
kind: "cancelled",
|
|
333
384
|
}
|
|
334
385
|
const out = toToolCallOutput(r)
|
|
335
|
-
expect(JSON.parse(out.output)).toEqual({ kind: "cancelled" })
|
|
386
|
+
expect(JSON.parse(out.output)).toEqual({ error: { kind: "cancelled" } })
|
|
336
387
|
})
|
|
337
388
|
})
|
|
338
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,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
|
+
})
|