@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.
Files changed (137) hide show
  1. package/dist/{AiError-CAX_48RU.d.mts → AiError-CrdR1EQi.d.mts} +2 -2
  2. package/dist/{AiError-CAX_48RU.d.mts.map → AiError-CrdR1EQi.d.mts.map} +1 -1
  3. package/dist/{Audio-DPlTsNdz.d.mts → Audio-DDSKCkSe.d.mts} +2 -2
  4. package/dist/{Audio-DPlTsNdz.d.mts.map → Audio-DDSKCkSe.d.mts.map} +1 -1
  5. package/dist/{Image-HNmMpMTh.d.mts → Image-C_Zjpwhc.d.mts} +2 -2
  6. package/dist/{Image-HNmMpMTh.d.mts.map → Image-C_Zjpwhc.d.mts.map} +1 -1
  7. package/dist/{Items-D8UaXQx_.d.mts → Items-CgKD6Npw.d.mts} +5 -5
  8. package/dist/{Items-D8UaXQx_.d.mts.map → Items-CgKD6Npw.d.mts.map} +1 -1
  9. package/dist/Loop-B5_cqumi.d.mts +173 -0
  10. package/dist/Loop-B5_cqumi.d.mts.map +1 -0
  11. package/dist/{Media-D_CpcM1Z.d.mts → Media-DOLyaygI.d.mts} +1 -1
  12. package/dist/{Media-D_CpcM1Z.d.mts.map → Media-DOLyaygI.d.mts.map} +1 -1
  13. package/dist/{SandboxError-BvKypkJm.d.mts → SandboxError-CIrVDOMV.d.mts} +2 -2
  14. package/dist/{SandboxError-BvKypkJm.d.mts.map → SandboxError-CIrVDOMV.d.mts.map} +1 -1
  15. package/dist/{StructuredFormat-BbN4dosH.d.mts → StructuredFormat-CMJFD3sn.d.mts} +2 -2
  16. package/dist/{StructuredFormat-BbN4dosH.d.mts.map → StructuredFormat-CMJFD3sn.d.mts.map} +1 -1
  17. package/dist/Tool-0XUIT2A2.d.mts +232 -0
  18. package/dist/Tool-0XUIT2A2.d.mts.map +1 -0
  19. package/dist/ToolEvent-DYrAUJGs.d.mts +151 -0
  20. package/dist/ToolEvent-DYrAUJGs.d.mts.map +1 -0
  21. package/dist/ToolResult-BZd-cEWW.d.mts +151 -0
  22. package/dist/ToolResult-BZd-cEWW.d.mts.map +1 -0
  23. package/dist/Toolkit-BKC8b8Xo.d.mts +155 -0
  24. package/dist/Toolkit-BKC8b8Xo.d.mts.map +1 -0
  25. package/dist/{dist-DV5ISja1.mjs → dist-B04dlFHg.mjs} +3 -3
  26. package/dist/{dist-DV5ISja1.mjs.map → dist-B04dlFHg.mjs.map} +1 -1
  27. package/dist/dist-DWgRbDN3.mjs +140 -0
  28. package/dist/dist-DWgRbDN3.mjs.map +1 -0
  29. package/dist/domain/AiError.d.mts +1 -1
  30. package/dist/domain/Audio.d.mts +1 -1
  31. package/dist/domain/Image.d.mts +1 -1
  32. package/dist/domain/Items.d.mts +1 -1
  33. package/dist/domain/Media.d.mts +1 -1
  34. package/dist/domain/Music.d.mts +1 -1
  35. package/dist/domain/Turn.d.mts +4 -4
  36. package/dist/domain/Turn.test.mjs +1 -1
  37. package/dist/embedding-model/Embedding.d.mts +1 -1
  38. package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
  39. package/dist/embedding-model/EmbeddingModel.test.mjs +1 -1
  40. package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -1
  41. package/dist/index.d.mts +16 -13
  42. package/dist/index.mjs +5 -2
  43. package/dist/language-model/LanguageModel.d.mts +10 -5
  44. package/dist/language-model/LanguageModel.d.mts.map +1 -1
  45. package/dist/language-model/LanguageModel.mjs.map +1 -1
  46. package/dist/language-model/LanguageModel.test.mjs +1 -1
  47. package/dist/loop/Loop.d.mts +2 -141
  48. package/dist/loop/Loop.mjs +16 -20
  49. package/dist/loop/Loop.mjs.map +1 -1
  50. package/dist/loop/Loop.test.mjs +18 -1
  51. package/dist/loop/Loop.test.mjs.map +1 -1
  52. package/dist/{magic-string.es-BgIV5Mu3.mjs → magic-string.es-Dk-MMA0F.mjs} +1 -1
  53. package/dist/{magic-string.es-BgIV5Mu3.mjs.map → magic-string.es-Dk-MMA0F.mjs.map} +1 -1
  54. package/dist/music-generator/MusicGenerator.d.mts +2 -2
  55. package/dist/music-generator/MusicGenerator.test.mjs +1 -1
  56. package/dist/observability/Metrics.d.mts +116 -28
  57. package/dist/observability/Metrics.d.mts.map +1 -1
  58. package/dist/observability/Metrics.mjs +250 -38
  59. package/dist/observability/Metrics.mjs.map +1 -1
  60. package/dist/observability/Metrics.test.d.mts +1 -0
  61. package/dist/observability/Metrics.test.mjs +187 -0
  62. package/dist/observability/Metrics.test.mjs.map +1 -0
  63. package/dist/observability/Telemetry.d.mts +37 -0
  64. package/dist/observability/Telemetry.d.mts.map +1 -0
  65. package/dist/observability/Telemetry.mjs +57 -0
  66. package/dist/observability/Telemetry.mjs.map +1 -0
  67. package/dist/observability/Telemetry.test.d.mts +1 -0
  68. package/dist/observability/Telemetry.test.mjs +92 -0
  69. package/dist/observability/Telemetry.test.mjs.map +1 -0
  70. package/dist/retry/Retry.d.mts +2 -2
  71. package/dist/retry/Retry.test.mjs +1 -1
  72. package/dist/sandbox/Sandbox.d.mts +1 -1
  73. package/dist/sandbox/SandboxError.d.mts +1 -1
  74. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +2 -2
  75. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +1 -1
  76. package/dist/streaming/JSONL.d.mts +1 -1
  77. package/dist/streaming/JSONL.test.mjs +1 -1
  78. package/dist/streaming/SSE.test.mjs +1 -1
  79. package/dist/structured-format/StructuredFormat.d.mts +1 -1
  80. package/dist/structured-format/StructuredFormat.mjs.map +1 -1
  81. package/dist/structured-format/StructuredFormat.test.mjs +1 -1
  82. package/dist/testing/MockMusicGenerator.d.mts +1 -1
  83. package/dist/testing/MockProvider.d.mts +1 -1
  84. package/dist/testing/MockSpeechSynthesizer.d.mts +1 -1
  85. package/dist/tool/Approval.d.mts +3 -3
  86. package/dist/tool/Approval.test.mjs +35 -34
  87. package/dist/tool/Approval.test.mjs.map +1 -1
  88. package/dist/tool/HistoryCheck.d.mts +2 -2
  89. package/dist/tool/Tool.d.mts +2 -2
  90. package/dist/tool/Tool.mjs +112 -22
  91. package/dist/tool/Tool.mjs.map +1 -1
  92. package/dist/tool/Tool.test.mjs +41 -3
  93. package/dist/tool/Tool.test.mjs.map +1 -1
  94. package/dist/tool/ToolEvent.d.mts +2 -151
  95. package/dist/tool/ToolResult.d.mts +2 -138
  96. package/dist/tool/ToolResult.mjs +18 -3
  97. package/dist/tool/ToolResult.mjs.map +1 -1
  98. package/dist/tool/Toolkit.d.mts +2 -73
  99. package/dist/tool/Toolkit.mjs +106 -28
  100. package/dist/tool/Toolkit.mjs.map +1 -1
  101. package/dist/tool/Toolkit.test.mjs +113 -7
  102. package/dist/tool/Toolkit.test.mjs.map +1 -1
  103. package/dist/transcriber/Transcriber.d.mts +2 -2
  104. package/dist/transcriber/Transcriber.test.mjs +1 -1
  105. package/dist/web-search/WebSearch.d.mts +119 -0
  106. package/dist/web-search/WebSearch.d.mts.map +1 -0
  107. package/dist/web-search/WebSearch.mjs +42 -0
  108. package/dist/web-search/WebSearch.mjs.map +1 -0
  109. package/dist/web-search/WebSearchTool.d.mts +58 -0
  110. package/dist/web-search/WebSearchTool.d.mts.map +1 -0
  111. package/dist/web-search/WebSearchTool.mjs +69 -0
  112. package/dist/web-search/WebSearchTool.mjs.map +1 -0
  113. package/package.json +9 -1
  114. package/src/embedding-model/EmbeddingModel.test.ts +4 -0
  115. package/src/index.ts +3 -0
  116. package/src/language-model/LanguageModel.ts +7 -2
  117. package/src/loop/Loop.test.ts +22 -0
  118. package/src/loop/Loop.ts +27 -4
  119. package/src/observability/Metrics.test.ts +266 -0
  120. package/src/observability/Metrics.ts +505 -65
  121. package/src/observability/Telemetry.test.ts +123 -0
  122. package/src/observability/Telemetry.ts +96 -0
  123. package/src/structured-format/StructuredFormat.ts +4 -0
  124. package/src/tool/Approval.test.ts +22 -21
  125. package/src/tool/Tool.test.ts +55 -2
  126. package/src/tool/Tool.ts +221 -55
  127. package/src/tool/ToolResult.ts +22 -1
  128. package/src/tool/Toolkit.test.ts +168 -7
  129. package/src/tool/Toolkit.ts +336 -99
  130. package/src/web-search/WebSearch.ts +150 -0
  131. package/src/web-search/WebSearchTool.ts +106 -0
  132. package/dist/Tool-CiFXYjmR.d.mts +0 -102
  133. package/dist/Tool-CiFXYjmR.d.mts.map +0 -1
  134. package/dist/loop/Loop.d.mts.map +0 -1
  135. package/dist/tool/ToolEvent.d.mts.map +0 -1
  136. package/dist/tool/ToolResult.d.mts.map +0 -1
  137. 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, streaming } from "./Tool.js"
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 = streaming({
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
- 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 }),
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 = streaming({
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
- 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 }),
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 = [webSearch, bulkEmail, deleteDatabase]
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
 
@@ -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
+ })