@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.
Files changed (165) hide show
  1. package/dist/{AiError-CAX_48RU.d.mts → AiError-Bf1pq4BB.d.mts} +9 -4
  2. package/dist/{AiError-CAX_48RU.d.mts.map → AiError-Bf1pq4BB.d.mts.map} +1 -1
  3. package/dist/{Audio-DPlTsNdz.d.mts → Audio-CR7CSljJ.d.mts} +1 -1
  4. package/dist/{Audio-DPlTsNdz.d.mts.map → Audio-CR7CSljJ.d.mts.map} +1 -1
  5. package/dist/{Image-HNmMpMTh.d.mts → Image-BZmKfIdq.d.mts} +1 -1
  6. package/dist/{Image-HNmMpMTh.d.mts.map → Image-BZmKfIdq.d.mts.map} +1 -1
  7. package/dist/{Items-7ljaVxH_.d.mts → Items-BnIduEV2.d.mts} +7 -7
  8. package/dist/{Items-7ljaVxH_.d.mts.map → Items-BnIduEV2.d.mts.map} +1 -1
  9. package/dist/Loop-D8nWpowc.d.mts +173 -0
  10. package/dist/Loop-D8nWpowc.d.mts.map +1 -0
  11. package/dist/{SandboxError-BvKypkJm.d.mts → SandboxError-CIrVDOMV.d.mts} +2 -2
  12. package/dist/{SandboxError-BvKypkJm.d.mts.map → SandboxError-CIrVDOMV.d.mts.map} +1 -1
  13. package/dist/{StructuredFormat-BbN4dosH.d.mts → StructuredFormat-DHaUJBdd.d.mts} +2 -2
  14. package/dist/{StructuredFormat-BbN4dosH.d.mts.map → StructuredFormat-DHaUJBdd.d.mts.map} +1 -1
  15. package/dist/Tool-2lC_XMth.d.mts +284 -0
  16. package/dist/Tool-2lC_XMth.d.mts.map +1 -0
  17. package/dist/ToolEvent-cWZ2BnbS.d.mts +151 -0
  18. package/dist/ToolEvent-cWZ2BnbS.d.mts.map +1 -0
  19. package/dist/ToolResult-CL4hK_9z.d.mts +157 -0
  20. package/dist/ToolResult-CL4hK_9z.d.mts.map +1 -0
  21. package/dist/Toolkit-Dr9l_hNy.d.mts +196 -0
  22. package/dist/Toolkit-Dr9l_hNy.d.mts.map +1 -0
  23. package/dist/browser/Browser.d.mts +190 -0
  24. package/dist/browser/Browser.d.mts.map +1 -0
  25. package/dist/browser/Browser.mjs +34 -0
  26. package/dist/browser/Browser.mjs.map +1 -0
  27. package/dist/browser/BrowserError.d.mts +141 -0
  28. package/dist/browser/BrowserError.d.mts.map +1 -0
  29. package/dist/browser/BrowserError.mjs +89 -0
  30. package/dist/browser/BrowserError.mjs.map +1 -0
  31. package/dist/browser/BrowserTool.d.mts +58 -0
  32. package/dist/browser/BrowserTool.d.mts.map +1 -0
  33. package/dist/browser/BrowserTool.mjs +86 -0
  34. package/dist/browser/BrowserTool.mjs.map +1 -0
  35. package/dist/{dist-DV5ISja1.mjs → dist-B04dlFHg.mjs} +3 -3
  36. package/dist/{dist-DV5ISja1.mjs.map → dist-B04dlFHg.mjs.map} +1 -1
  37. package/dist/dist-DWgRbDN3.mjs +140 -0
  38. package/dist/dist-DWgRbDN3.mjs.map +1 -0
  39. package/dist/domain/AiError.d.mts +2 -2
  40. package/dist/domain/AiError.mjs +22 -3
  41. package/dist/domain/AiError.mjs.map +1 -1
  42. package/dist/domain/Audio.d.mts +1 -1
  43. package/dist/domain/Image.d.mts +1 -1
  44. package/dist/domain/Items.d.mts +1 -1
  45. package/dist/domain/Music.d.mts +1 -1
  46. package/dist/domain/Turn.d.mts +4 -4
  47. package/dist/domain/Turn.test.mjs +1 -1
  48. package/dist/embedding-model/Embedding.d.mts +1 -1
  49. package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
  50. package/dist/embedding-model/EmbeddingModel.test.mjs +1 -1
  51. package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -1
  52. package/dist/index.d.mts +19 -13
  53. package/dist/index.mjs +15 -9
  54. package/dist/language-model/LanguageModel.d.mts +10 -5
  55. package/dist/language-model/LanguageModel.d.mts.map +1 -1
  56. package/dist/language-model/LanguageModel.mjs.map +1 -1
  57. package/dist/language-model/LanguageModel.test.mjs +1 -1
  58. package/dist/loop/Loop.d.mts +2 -141
  59. package/dist/loop/Loop.mjs +16 -20
  60. package/dist/loop/Loop.mjs.map +1 -1
  61. package/dist/loop/Loop.test.mjs +18 -1
  62. package/dist/loop/Loop.test.mjs.map +1 -1
  63. package/dist/{magic-string.es-BgIV5Mu3.mjs → magic-string.es-Dk-MMA0F.mjs} +1 -1
  64. package/dist/{magic-string.es-BgIV5Mu3.mjs.map → magic-string.es-Dk-MMA0F.mjs.map} +1 -1
  65. package/dist/music-generator/MusicGenerator.d.mts +2 -2
  66. package/dist/music-generator/MusicGenerator.test.mjs +1 -1
  67. package/dist/observability/Metrics.d.mts +116 -28
  68. package/dist/observability/Metrics.d.mts.map +1 -1
  69. package/dist/observability/Metrics.mjs +250 -38
  70. package/dist/observability/Metrics.mjs.map +1 -1
  71. package/dist/observability/Metrics.test.d.mts +1 -0
  72. package/dist/observability/Metrics.test.mjs +187 -0
  73. package/dist/observability/Metrics.test.mjs.map +1 -0
  74. package/dist/observability/Telemetry.d.mts +37 -0
  75. package/dist/observability/Telemetry.d.mts.map +1 -0
  76. package/dist/observability/Telemetry.mjs +57 -0
  77. package/dist/observability/Telemetry.mjs.map +1 -0
  78. package/dist/observability/Telemetry.test.d.mts +1 -0
  79. package/dist/observability/Telemetry.test.mjs +92 -0
  80. package/dist/observability/Telemetry.test.mjs.map +1 -0
  81. package/dist/retry/Retry.d.mts +2 -2
  82. package/dist/retry/Retry.test.mjs +1 -1
  83. package/dist/sandbox/Sandbox.d.mts +1 -1
  84. package/dist/sandbox/SandboxError.d.mts +1 -1
  85. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +2 -2
  86. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +1 -1
  87. package/dist/streaming/JSONL.d.mts +1 -1
  88. package/dist/streaming/JSONL.test.mjs +1 -1
  89. package/dist/streaming/SSE.test.mjs +1 -1
  90. package/dist/structured-format/StructuredFormat.d.mts +1 -1
  91. package/dist/structured-format/StructuredFormat.mjs.map +1 -1
  92. package/dist/structured-format/StructuredFormat.test.mjs +1 -1
  93. package/dist/testing/MockMusicGenerator.d.mts +1 -1
  94. package/dist/testing/MockProvider.d.mts +1 -1
  95. package/dist/testing/MockSpeechSynthesizer.d.mts +1 -1
  96. package/dist/tool/Approval.d.mts +19 -7
  97. package/dist/tool/Approval.d.mts.map +1 -1
  98. package/dist/tool/Approval.mjs +18 -11
  99. package/dist/tool/Approval.mjs.map +1 -1
  100. package/dist/tool/Approval.test.mjs +80 -40
  101. package/dist/tool/Approval.test.mjs.map +1 -1
  102. package/dist/tool/HistoryCheck.d.mts +2 -2
  103. package/dist/tool/Tool.d.mts +2 -2
  104. package/dist/tool/Tool.mjs +190 -36
  105. package/dist/tool/Tool.mjs.map +1 -1
  106. package/dist/tool/Tool.test.mjs +41 -3
  107. package/dist/tool/Tool.test.mjs.map +1 -1
  108. package/dist/tool/ToolEvent.d.mts +2 -151
  109. package/dist/tool/ToolEvent.mjs +5 -0
  110. package/dist/tool/ToolEvent.mjs.map +1 -1
  111. package/dist/tool/ToolResult.d.mts +2 -138
  112. package/dist/tool/ToolResult.mjs +32 -8
  113. package/dist/tool/ToolResult.mjs.map +1 -1
  114. package/dist/tool/Toolkit.d.mts +2 -73
  115. package/dist/tool/Toolkit.mjs +153 -35
  116. package/dist/tool/Toolkit.mjs.map +1 -1
  117. package/dist/tool/Toolkit.test.mjs +271 -11
  118. package/dist/tool/Toolkit.test.mjs.map +1 -1
  119. package/dist/transcriber/Transcriber.d.mts +2 -2
  120. package/dist/transcriber/Transcriber.test.mjs +1 -1
  121. package/dist/web-read/WebRead.d.mts +44 -0
  122. package/dist/web-read/WebRead.d.mts.map +1 -0
  123. package/dist/web-read/WebRead.mjs +18 -0
  124. package/dist/web-read/WebRead.mjs.map +1 -0
  125. package/dist/web-read/WebReadTool.d.mts +57 -0
  126. package/dist/web-read/WebReadTool.d.mts.map +1 -0
  127. package/dist/web-read/WebReadTool.mjs +59 -0
  128. package/dist/web-read/WebReadTool.mjs.map +1 -0
  129. package/dist/web-search/WebSearch.d.mts +1 -1
  130. package/dist/web-search/WebSearchTool.d.mts +12 -6
  131. package/dist/web-search/WebSearchTool.d.mts.map +1 -1
  132. package/dist/web-search/WebSearchTool.mjs +10 -5
  133. package/dist/web-search/WebSearchTool.mjs.map +1 -1
  134. package/package.json +21 -1
  135. package/src/browser/Browser.ts +268 -0
  136. package/src/browser/BrowserError.ts +145 -0
  137. package/src/browser/BrowserTool.ts +161 -0
  138. package/src/domain/AiError.ts +27 -1
  139. package/src/embedding-model/EmbeddingModel.test.ts +4 -0
  140. package/src/index.ts +6 -0
  141. package/src/language-model/LanguageModel.ts +7 -2
  142. package/src/loop/Loop.test.ts +22 -0
  143. package/src/loop/Loop.ts +27 -4
  144. package/src/observability/Metrics.test.ts +266 -0
  145. package/src/observability/Metrics.ts +505 -65
  146. package/src/observability/Telemetry.test.ts +123 -0
  147. package/src/observability/Telemetry.ts +96 -0
  148. package/src/structured-format/StructuredFormat.ts +4 -0
  149. package/src/tool/Approval.test.ts +77 -26
  150. package/src/tool/Approval.ts +72 -27
  151. package/src/tool/Tool.test.ts +55 -2
  152. package/src/tool/Tool.ts +335 -70
  153. package/src/tool/ToolEvent.ts +5 -0
  154. package/src/tool/ToolResult.ts +36 -9
  155. package/src/tool/Toolkit.test.ts +339 -9
  156. package/src/tool/Toolkit.ts +470 -120
  157. package/src/web-read/WebRead.ts +70 -0
  158. package/src/web-read/WebReadTool.ts +96 -0
  159. package/src/web-search/WebSearchTool.ts +11 -5
  160. package/dist/Tool-BKiieFXk.d.mts +0 -102
  161. package/dist/Tool-BKiieFXk.d.mts.map +0 -1
  162. package/dist/loop/Loop.d.mts.map +0 -1
  163. package/dist/tool/ToolEvent.d.mts.map +0 -1
  164. package/dist/tool/ToolResult.d.mts.map +0 -1
  165. 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 { type ApprovalMapEntry, type ApprovalDecision, fromMap, fromQueue } from "./Approval.js"
19
- import { fromEffectSchema, make as makeTool, streaming } from "./Tool.js"
20
- import { run } from "./Toolkit.js"
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 = streaming({
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
- Stream.fromIterable([
36
- { url: "a", title: `${query} 1` },
37
- { url: "b", title: `${query} 2` },
38
- { url: "c", title: `${query} 3` },
39
- ]),
40
- finalize: (hits) => ({ count: hits.length }),
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 = streaming({
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
- Stream.fromIterable(
51
- recipients.map((_, i) => ({
52
- type: "progress" as const,
53
- sent: i + 1,
54
- total: recipients.length,
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 = [webSearch, bulkEmail, deleteDatabase]
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("round-trips a Failure result with reason", () => {
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", reason: "spam concern" })
375
+ expect(JSON.parse(out.output)).toEqual({ error: { kind: "denied", message: "spam concern" } })
325
376
  })
326
377
 
327
- it("round-trips a Failure result without reason (omits the field)", () => {
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
 
@@ -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
- (predicate: (call: ToolCall) => boolean, verdicts: Queue.Dequeue<Verdict>) =>
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<Verdict>().pipe(Effect.map((d) => [call.call_id, d] as const)),
96
+ Deferred.make<ApprovalDecision>().pipe(Effect.map((d) => [call.call_id, d] as const)),
80
97
  )
81
- const deferreds: ReadonlyMap<string, Deferred.Deferred<Verdict>> = new Map(entries)
82
-
83
- // Router is forked into the surrounding Scope so it lives as long
84
- // as the consumer is pulling events. Recipes typically supply the
85
- // scope by wrapping the events construction in `Stream.unwrap`.
86
- yield* Effect.forkScoped(
87
- Effect.forever(
88
- Effect.gen(function* () {
89
- const v = yield* Queue.take(verdicts)
90
- const d = deferreds.get(v.call_id)
91
- if (d !== undefined) yield* Deferred.succeed(d, v)
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
- const decisions = Stream.fromIterable(gated).pipe(
97
- Stream.flatMap(
98
- (call) => {
99
- const d = deferreds.get(call.call_id)!
100
- return Stream.fromEffect(
101
- Deferred.await(d).pipe(
102
- Effect.map((v) =>
103
- v.decision === "approve" ? approve(call) : reject(denied(call, v.reason)),
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
- { concurrency: "unbounded" },
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))
@@ -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, unknown, never> ? I : never
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
+ })