@effect-uai/core 0.4.0 → 0.5.1

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 (124) hide show
  1. package/dist/{AiError-csR8Bhxx.d.mts → AiError-CAX_48RU.d.mts} +2 -2
  2. package/dist/{AiError-csR8Bhxx.d.mts.map → AiError-CAX_48RU.d.mts.map} +1 -1
  3. package/dist/{Image-DxyXqzAM.d.mts → Image-HNmMpMTh.d.mts} +4 -4
  4. package/dist/{Image-DxyXqzAM.d.mts.map → Image-HNmMpMTh.d.mts.map} +1 -1
  5. package/dist/{Items-Hg5AsYxl.d.mts → Items-BH8xUkoR.d.mts} +3 -3
  6. package/dist/{Items-Hg5AsYxl.d.mts.map → Items-BH8xUkoR.d.mts.map} +1 -1
  7. package/dist/{StructuredFormat-Cl41C56K.d.mts → StructuredFormat-BbN4dosH.d.mts} +11 -4
  8. package/dist/StructuredFormat-BbN4dosH.d.mts.map +1 -0
  9. package/dist/{Tool-B8B5qVEy.d.mts → Tool-87ViKCCO.d.mts} +20 -4
  10. package/dist/Tool-87ViKCCO.d.mts.map +1 -0
  11. package/dist/Turn-0CwCAyVe.d.mts +388 -0
  12. package/dist/Turn-0CwCAyVe.d.mts.map +1 -0
  13. package/dist/domain/AiError.d.mts +1 -1
  14. package/dist/domain/AiError.mjs +1 -1
  15. package/dist/domain/AiError.mjs.map +1 -1
  16. package/dist/domain/Image.d.mts +1 -1
  17. package/dist/domain/Items.d.mts +1 -1
  18. package/dist/domain/Items.mjs +1 -1
  19. package/dist/domain/Items.mjs.map +1 -1
  20. package/dist/domain/Turn.d.mts +2 -2
  21. package/dist/domain/Turn.mjs +22 -4
  22. package/dist/domain/Turn.mjs.map +1 -1
  23. package/dist/domain/Turn.test.d.mts +1 -0
  24. package/dist/domain/Turn.test.mjs +136 -0
  25. package/dist/domain/Turn.test.mjs.map +1 -0
  26. package/dist/embedding-model/Embedding.d.mts +15 -3
  27. package/dist/embedding-model/Embedding.d.mts.map +1 -1
  28. package/dist/embedding-model/Embedding.mjs.map +1 -1
  29. package/dist/embedding-model/EmbeddingModel.d.mts +33 -17
  30. package/dist/embedding-model/EmbeddingModel.d.mts.map +1 -1
  31. package/dist/embedding-model/EmbeddingModel.mjs.map +1 -1
  32. package/dist/embedding-model/EmbeddingModel.test.d.mts +1 -0
  33. package/dist/embedding-model/EmbeddingModel.test.mjs +59 -0
  34. package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -0
  35. package/dist/index.d.mts +6 -6
  36. package/dist/language-model/LanguageModel.d.mts +30 -8
  37. package/dist/language-model/LanguageModel.d.mts.map +1 -1
  38. package/dist/language-model/LanguageModel.mjs +33 -3
  39. package/dist/language-model/LanguageModel.mjs.map +1 -1
  40. package/dist/language-model/LanguageModel.test.d.mts +1 -0
  41. package/dist/language-model/LanguageModel.test.mjs +143 -0
  42. package/dist/language-model/LanguageModel.test.mjs.map +1 -0
  43. package/dist/loop/Loop.d.mts +94 -11
  44. package/dist/loop/Loop.d.mts.map +1 -1
  45. package/dist/loop/Loop.mjs +92 -26
  46. package/dist/loop/Loop.mjs.map +1 -1
  47. package/dist/loop/Loop.test.mjs +171 -3
  48. package/dist/loop/Loop.test.mjs.map +1 -1
  49. package/dist/music-generator/MusicGenerator.d.mts +1 -1
  50. package/dist/observability/Metrics.d.mts +1 -1
  51. package/dist/observability/Metrics.mjs +1 -1
  52. package/dist/observability/Metrics.mjs.map +1 -1
  53. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +1 -1
  54. package/dist/streaming/JSONL.d.mts +1 -1
  55. package/dist/streaming/JSONL.d.mts.map +1 -1
  56. package/dist/streaming/JSONL.mjs +7 -12
  57. package/dist/streaming/JSONL.mjs.map +1 -1
  58. package/dist/structured-format/StructuredFormat.d.mts +2 -2
  59. package/dist/structured-format/StructuredFormat.mjs +9 -1
  60. package/dist/structured-format/StructuredFormat.mjs.map +1 -1
  61. package/dist/structured-format/StructuredFormat.test.d.mts +1 -0
  62. package/dist/structured-format/StructuredFormat.test.mjs +70 -0
  63. package/dist/structured-format/StructuredFormat.test.mjs.map +1 -0
  64. package/dist/testing/MockMusicGenerator.d.mts.map +1 -1
  65. package/dist/testing/MockMusicGenerator.mjs +2 -2
  66. package/dist/testing/MockMusicGenerator.mjs.map +1 -1
  67. package/dist/testing/MockProvider.d.mts +23 -18
  68. package/dist/testing/MockProvider.d.mts.map +1 -1
  69. package/dist/testing/MockProvider.mjs +56 -72
  70. package/dist/testing/MockProvider.mjs.map +1 -1
  71. package/dist/testing/MockSpeechSynthesizer.d.mts.map +1 -1
  72. package/dist/testing/MockSpeechSynthesizer.mjs +2 -2
  73. package/dist/testing/MockSpeechSynthesizer.mjs.map +1 -1
  74. package/dist/testing/MockTranscriber.d.mts.map +1 -1
  75. package/dist/testing/MockTranscriber.mjs +2 -2
  76. package/dist/testing/MockTranscriber.mjs.map +1 -1
  77. package/dist/tool/HistoryCheck.d.mts +1 -1
  78. package/dist/tool/Outcome.d.mts +1 -1
  79. package/dist/tool/Resolvers.d.mts +65 -8
  80. package/dist/tool/Resolvers.d.mts.map +1 -1
  81. package/dist/tool/Resolvers.mjs +8 -12
  82. package/dist/tool/Resolvers.mjs.map +1 -1
  83. package/dist/tool/Resolvers.test.mjs +6 -5
  84. package/dist/tool/Resolvers.test.mjs.map +1 -1
  85. package/dist/tool/Tool.d.mts +2 -2
  86. package/dist/tool/Tool.mjs +18 -1
  87. package/dist/tool/Tool.mjs.map +1 -1
  88. package/dist/tool/Tool.test.d.mts +1 -0
  89. package/dist/tool/Tool.test.mjs +66 -0
  90. package/dist/tool/Tool.test.mjs.map +1 -0
  91. package/dist/tool/Toolkit.d.mts +4 -6
  92. package/dist/tool/Toolkit.d.mts.map +1 -1
  93. package/dist/tool/Toolkit.mjs +14 -43
  94. package/dist/tool/Toolkit.mjs.map +1 -1
  95. package/dist/transcriber/Transcriber.d.mts +1 -1
  96. package/package.json +1 -1
  97. package/src/domain/AiError.ts +1 -1
  98. package/src/domain/Items.ts +1 -1
  99. package/src/domain/Turn.test.ts +141 -0
  100. package/src/domain/Turn.ts +50 -43
  101. package/src/embedding-model/Embedding.ts +23 -0
  102. package/src/embedding-model/EmbeddingModel.test.ts +92 -0
  103. package/src/embedding-model/EmbeddingModel.ts +30 -20
  104. package/src/language-model/LanguageModel.test.ts +170 -0
  105. package/src/language-model/LanguageModel.ts +64 -1
  106. package/src/loop/Loop.test.ts +256 -3
  107. package/src/loop/Loop.ts +225 -49
  108. package/src/observability/Metrics.ts +1 -1
  109. package/src/streaming/JSONL.ts +9 -18
  110. package/src/structured-format/StructuredFormat.test.ts +105 -0
  111. package/src/structured-format/StructuredFormat.ts +14 -1
  112. package/src/testing/MockMusicGenerator.ts +4 -6
  113. package/src/testing/MockProvider.ts +126 -105
  114. package/src/testing/MockSpeechSynthesizer.ts +4 -6
  115. package/src/testing/MockTranscriber.ts +4 -6
  116. package/src/tool/Resolvers.test.ts +8 -5
  117. package/src/tool/Resolvers.ts +17 -19
  118. package/src/tool/Tool.test.ts +105 -0
  119. package/src/tool/Tool.ts +20 -0
  120. package/src/tool/Toolkit.ts +49 -50
  121. package/dist/StructuredFormat-Cl41C56K.d.mts.map +0 -1
  122. package/dist/Tool-B8B5qVEy.d.mts.map +0 -1
  123. package/dist/Turn-7geUcKsf.d.mts +0 -194
  124. package/dist/Turn-7geUcKsf.d.mts.map +0 -1
@@ -1,8 +1,8 @@
1
- import { Duration, Effect, Layer, Ref, Schedule, Stream } from "effect"
1
+ import { Array as Arr, Duration, Effect, Layer, Match, Option, Ref, Schedule, Stream } from "effect"
2
2
  import * as AiError from "../domain/AiError.js"
3
- import type { Item } from "../domain/Items.js"
3
+ import { type Item, isOutputText } from "../domain/Items.js"
4
4
  import { LanguageModel, type LanguageModelService } from "../language-model/LanguageModel.js"
5
- import type { Turn, TurnEvent } from "../domain/Turn.js"
5
+ import { type Turn, TurnEvent } from "../domain/Turn.js"
6
6
 
7
7
  export type MockOptions = {
8
8
  /**
@@ -13,46 +13,48 @@ export type MockOptions = {
13
13
  readonly deltaInterval?: Duration.Input
14
14
  }
15
15
 
16
+ export type Call = {
17
+ readonly history: ReadonlyArray<Item>
18
+ readonly turn: Turn
19
+ }
20
+
16
21
  /**
17
22
  * A scripted mock provider. Pre-canned `Turn` outputs are returned in order,
18
23
  * one per call to `streamTurn`. Each scripted turn is split into synthetic
19
- * deltas (text → tool_call_starttool_call_args_delta → ... → turn_complete)
24
+ * deltas (text → ToolCallStartToolCallArgsDelta → ... → TurnComplete)
20
25
  * so streaming consumers can see realistic delta shapes.
21
26
  */
22
27
  export type MockRecorder = {
23
- readonly calls: ReadonlyArray<{
24
- readonly history: ReadonlyArray<Item>
25
- readonly turn: Turn
26
- }>
28
+ readonly calls: ReadonlyArray<Call>
27
29
  }
28
30
 
29
- const turnToDeltas = (turn: Turn): ReadonlyArray<TurnEvent> => {
30
- const deltas: TurnEvent[] = []
31
- for (const item of turn.items) {
32
- if (item.type === "message" && item.role === "assistant") {
33
- for (const block of item.content) {
34
- if (block.type === "output_text") {
35
- deltas.push({ type: "text_delta", text: block.text })
36
- }
37
- }
38
- } else if (item.type === "function_call") {
39
- deltas.push({
40
- type: "tool_call_start",
41
- call_id: item.call_id,
42
- name: item.name,
43
- })
44
- deltas.push({
45
- type: "tool_call_args_delta",
46
- call_id: item.call_id,
47
- delta: item.arguments,
48
- })
49
- } else if (item.type === "reasoning" && item.summary !== undefined) {
50
- deltas.push({ type: "reasoning_delta", text: item.summary, kind: "summary" })
51
- }
52
- }
53
- deltas.push({ type: "turn_complete", turn })
54
- return deltas
55
- }
31
+ // ---------------------------------------------------------------------------
32
+ // Pure projection: Turn ReadonlyArray<TurnEvent>
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const itemToDeltas: (item: Item) => ReadonlyArray<TurnEvent> = Match.type<Item>().pipe(
36
+ Match.discriminators("type")({
37
+ message: (m): ReadonlyArray<TurnEvent> =>
38
+ m.role === "assistant"
39
+ ? m.content.filter(isOutputText).map((b) => TurnEvent.TextDelta({ text: b.text }))
40
+ : [],
41
+ function_call: (fc) => [
42
+ TurnEvent.ToolCallStart({ call_id: fc.call_id, name: fc.name }),
43
+ TurnEvent.ToolCallArgsDelta({ call_id: fc.call_id, delta: fc.arguments }),
44
+ ],
45
+ function_call_output: () => [],
46
+ reasoning: (r) =>
47
+ r.summary !== undefined
48
+ ? [TurnEvent.ReasoningDelta({ text: r.summary, kind: "summary" as const })]
49
+ : [],
50
+ }),
51
+ Match.exhaustive,
52
+ )
53
+
54
+ const turnToDeltas = (turn: Turn): ReadonlyArray<TurnEvent> => [
55
+ ...turn.items.flatMap(itemToDeltas),
56
+ TurnEvent.TurnComplete({ turn }),
57
+ ]
56
58
 
57
59
  const pacedDeltas = (turn: Turn, options?: MockOptions): Stream.Stream<TurnEvent> => {
58
60
  const base = Stream.fromIterable(turnToDeltas(turn))
@@ -61,101 +63,120 @@ const pacedDeltas = (turn: Turn, options?: MockOptions): Stream.Stream<TurnEvent
61
63
  : base.pipe(Stream.schedule(Schedule.spaced(options.deltaInterval)))
62
64
  }
63
65
 
64
- const makeService = (
66
+ // ---------------------------------------------------------------------------
67
+ // Canonical service factory. One implementation; sync/Layer/recorder
68
+ // variants below are just different ways to wire the cursor + record hook.
69
+ // ---------------------------------------------------------------------------
70
+
71
+ const exhausted = (n: number, attempt: number): AiError.AiError =>
72
+ new AiError.InvalidRequest({
73
+ provider: "mock",
74
+ raw: `MockProvider exhausted: ${n} turns scripted, but call ${attempt} was made`,
75
+ })
76
+
77
+ const noRecord = (_: Call): Effect.Effect<void> => Effect.void
78
+
79
+ const buildService = (
65
80
  scriptedTurns: ReadonlyArray<Turn>,
66
- options?: MockOptions,
67
- recordCall?: (history: ReadonlyArray<Item>, turn: Turn) => Effect.Effect<void>,
68
- ) =>
69
- Effect.gen(function* () {
70
- const cursor = yield* Ref.make(0)
71
- return LanguageModel.of({
72
- streamTurn: (request) =>
73
- Stream.unwrap(
74
- Effect.gen(function* () {
75
- const i = yield* Ref.getAndUpdate(cursor, (n) => n + 1)
76
- if (i >= scriptedTurns.length) {
77
- return Stream.fail(
78
- new AiError.InvalidRequest({
79
- provider: "mock",
80
- raw: `MockProvider exhausted: ${scriptedTurns.length} turns scripted, but call ${i + 1} was made`,
81
- }),
82
- )
83
- }
84
- const turn = scriptedTurns[i]!
85
- if (recordCall !== undefined) {
86
- yield* recordCall(request.history, turn)
87
- }
88
- return pacedDeltas(turn, options)
89
- }),
81
+ options: MockOptions | undefined,
82
+ cursor: Ref.Ref<number>,
83
+ record: (call: Call) => Effect.Effect<void>,
84
+ ): LanguageModelService => ({
85
+ streamTurn: (request) =>
86
+ Stream.unwrap(
87
+ Ref.getAndUpdate(cursor, (n) => n + 1).pipe(
88
+ Effect.flatMap(
89
+ (i): Effect.Effect<Stream.Stream<TurnEvent, AiError.AiError>> =>
90
+ Option.match(Arr.get(scriptedTurns, i), {
91
+ onNone: () => Effect.succeed(Stream.fail(exhausted(scriptedTurns.length, i + 1))),
92
+ onSome: (turn) =>
93
+ record({ history: request.history, turn }).pipe(
94
+ Effect.as(pacedDeltas(turn, options)),
95
+ ),
96
+ }),
90
97
  ),
91
- })
92
- })
98
+ ),
99
+ ),
100
+ })
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Recorder handle. Unsafe Ref is local: it backs both the `record` write
104
+ // hook (called inside the service) and the `recorder` read effect (called
105
+ // by the test). Both close over the same cell.
106
+ // ---------------------------------------------------------------------------
107
+
108
+ type RecorderHandle = {
109
+ readonly record: (call: Call) => Effect.Effect<void>
110
+ readonly recorder: Effect.Effect<MockRecorder>
111
+ }
93
112
 
113
+ const makeRecorderUnsafe = (): RecorderHandle => {
114
+ const ref = Ref.makeUnsafe<ReadonlyArray<Call>>([])
115
+ return {
116
+ record: (call) => Ref.update(ref, Arr.append(call)),
117
+ recorder: Ref.get(ref).pipe(Effect.map((calls): MockRecorder => ({ calls }))),
118
+ }
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Public API
123
+ // ---------------------------------------------------------------------------
124
+
125
+ /**
126
+ * Layer that registers a `MockProvider` against the `LanguageModel` tag.
127
+ * Calls beyond the scripted turn count fail with `InvalidRequest`.
128
+ */
94
129
  export const layer = (
95
130
  scriptedTurns: ReadonlyArray<Turn>,
96
131
  options?: MockOptions,
97
- ): Layer.Layer<LanguageModel> => Layer.effect(LanguageModel, makeService(scriptedTurns, options))
132
+ ): Layer.Layer<LanguageModel> =>
133
+ Layer.effect(
134
+ LanguageModel,
135
+ Ref.make(0).pipe(
136
+ Effect.map((cursor) => buildService(scriptedTurns, options, cursor, noRecord)),
137
+ ),
138
+ )
98
139
 
99
140
  /**
100
- * Synchronous constructor that returns the `LanguageModelService` value
101
- * directly, plus a recorder. Use this when you want to swap models
102
- * mid-stream via `Effect.provideService` instead of providing one model
103
- * for the whole program via `Layer`.
141
+ * Like `layer`, but also exposes a recorder that captures every call
142
+ * (history + returned turn).
104
143
  */
105
- export const make = (
144
+ export const layerWithRecorder = (
106
145
  scriptedTurns: ReadonlyArray<Turn>,
107
146
  options?: MockOptions,
108
147
  ): {
109
- readonly service: LanguageModelService
148
+ readonly layer: Layer.Layer<LanguageModel>
110
149
  readonly recorder: Effect.Effect<MockRecorder>
111
150
  } => {
112
- const cursor = Ref.makeUnsafe(0)
113
- const callsRef = Ref.makeUnsafe<ReadonlyArray<{ history: ReadonlyArray<Item>; turn: Turn }>>([])
114
- const service: LanguageModelService = {
115
- streamTurn: (request) =>
116
- Stream.unwrap(
117
- Effect.gen(function* () {
118
- const i = yield* Ref.getAndUpdate(cursor, (n) => n + 1)
119
- if (i >= scriptedTurns.length) {
120
- return Stream.fail(
121
- new AiError.InvalidRequest({
122
- provider: "mock",
123
- raw: `MockProvider exhausted: ${scriptedTurns.length} turns scripted, but call ${i + 1} was made`,
124
- }),
125
- )
126
- }
127
- const turn = scriptedTurns[i]!
128
- yield* Ref.update(callsRef, (xs) => [...xs, { history: request.history, turn }])
129
- return pacedDeltas(turn, options)
130
- }),
131
- ),
132
- }
151
+ const { record, recorder } = makeRecorderUnsafe()
133
152
  return {
134
- service,
135
- recorder: Ref.get(callsRef).pipe(Effect.map((calls) => ({ calls }))),
153
+ layer: Layer.effect(
154
+ LanguageModel,
155
+ Ref.make(0).pipe(
156
+ Effect.map((cursor) => buildService(scriptedTurns, options, cursor, record)),
157
+ ),
158
+ ),
159
+ recorder,
136
160
  }
137
161
  }
138
162
 
139
163
  /**
140
- * Same as `layer`, but also exposes a recorder that captures every call
141
- * (history + returned turn).
164
+ * Build the `LanguageModelService` value directly (no Layer), plus a
165
+ * recorder. Use this when you want to swap models mid-program via
166
+ * `Effect.provideService` instead of providing one model for the whole
167
+ * program via `Layer`.
142
168
  */
143
- export const layerWithRecorder = (
169
+ export const make = (
144
170
  scriptedTurns: ReadonlyArray<Turn>,
145
171
  options?: MockOptions,
146
172
  ): {
147
- readonly layer: Layer.Layer<LanguageModel>
173
+ readonly service: LanguageModelService
148
174
  readonly recorder: Effect.Effect<MockRecorder>
149
175
  } => {
150
- const callsRef = Ref.makeUnsafe<ReadonlyArray<{ history: ReadonlyArray<Item>; turn: Turn }>>([])
151
- const live = Layer.effect(
152
- LanguageModel,
153
- makeService(scriptedTurns, options, (history, turn) =>
154
- Ref.update(callsRef, (xs) => [...xs, { history, turn }]),
155
- ),
156
- )
176
+ const cursor = Ref.makeUnsafe(0)
177
+ const { record, recorder } = makeRecorderUnsafe()
157
178
  return {
158
- layer: live,
159
- recorder: Ref.get(callsRef).pipe(Effect.map((calls) => ({ calls }))),
179
+ service: buildService(scriptedTurns, options, cursor, record),
180
+ recorder,
160
181
  }
161
182
  }
@@ -43,12 +43,10 @@ const makeService = (
43
43
  const i = yield* Ref.getAndUpdate(bCursor, (n) => n + 1)
44
44
  const scripted = script.blobs ?? []
45
45
  if (i >= scripted.length) {
46
- return yield* Effect.fail(
47
- new AiError.InvalidRequest({
48
- provider: "mock",
49
- raw: `MockSpeechSynthesizer exhausted: ${scripted.length} blobs scripted, but call ${i + 1} was made`,
50
- }),
51
- )
46
+ return yield* new AiError.InvalidRequest({
47
+ provider: "mock",
48
+ raw: `MockSpeechSynthesizer exhausted: ${scripted.length} blobs scripted, but call ${i + 1} was made`,
49
+ })
52
50
  }
53
51
  return scripted[i]!
54
52
  }),
@@ -41,12 +41,10 @@ const makeService = (
41
41
  const i = yield* Ref.getAndUpdate(tCursor, (n) => n + 1)
42
42
  const scripted = script.transcripts ?? []
43
43
  if (i >= scripted.length) {
44
- return yield* Effect.fail(
45
- new AiError.InvalidRequest({
46
- provider: "mock",
47
- raw: `MockTranscriber exhausted: ${scripted.length} transcripts scripted, but call ${i + 1} was made`,
48
- }),
49
- )
44
+ return yield* new AiError.InvalidRequest({
45
+ provider: "mock",
46
+ raw: `MockTranscriber exhausted: ${scripted.length} transcripts scripted, but call ${i + 1} was made`,
47
+ })
50
48
  }
51
49
  return scripted[i]!
52
50
  }),
@@ -22,8 +22,8 @@ import {
22
22
  fromVerdictQueue,
23
23
  } from "./Resolvers.js"
24
24
  import { fromEffectSchema, make as makeTool, streaming } from "./Tool.js"
25
- import { executeAll, outputEvent, outputEvents } from "./Toolkit.js"
26
- import { type ToolEvent, isApprovalRequested, isIntermediate, isOutput } from "./ToolEvent.js"
25
+ import { executeAll } from "./Toolkit.js"
26
+ import { ToolEvent, isApprovalRequested, isIntermediate, isOutput } from "./ToolEvent.js"
27
27
 
28
28
  // ---------------------------------------------------------------------------
29
29
  // Three demo tools covering the matrix:
@@ -91,15 +91,18 @@ const resultsFrom = (collected: ReadonlyArray<ToolEvent>): ReadonlyArray<ToolRes
91
91
 
92
92
  const byCallId = (results: ReadonlyArray<ToolResult>) => new Map(results.map((r) => [r.call_id, r]))
93
93
 
94
+ const rejectedStream = (rejected: ReadonlyArray<ToolResult>) =>
95
+ Stream.fromIterable(rejected.map((result) => ToolEvent.Output({ result })))
96
+
94
97
  const eventsFromApprovalMap = (approvals: ReadonlyMap<string, ApprovalMapEntry>) => {
95
98
  const plan = fromApprovalMap(isSensitive, approvals)(calls)
96
- return Stream.merge(executeAll(allTools, plan.approved), outputEvents(plan.rejected))
99
+ return Stream.merge(executeAll(allTools, plan.approved), rejectedStream(plan.rejected))
97
100
  }
98
101
 
99
102
  const eventsFromDecision = (decision: ToolCallDecision): Stream.Stream<ToolEvent> =>
100
103
  decision._tag === "Approved"
101
104
  ? executeAll(allTools, [decision.call])
102
- : Stream.succeed(outputEvent(decision.result))
105
+ : Stream.succeed(ToolEvent.Output({ result: decision.result }))
103
106
 
104
107
  // ---------------------------------------------------------------------------
105
108
  // fromApprovalMap: HTTP-style scenarios
@@ -189,7 +192,7 @@ describe("executeAll: graceful degradation", () => {
189
192
  isSensitive,
190
193
  new Map([["c3", { decision: "approve" }]]),
191
194
  )(callsWithBogus)
192
- return Stream.merge(executeAll(allTools, plan.approved), outputEvents(plan.rejected))
195
+ return Stream.merge(executeAll(allTools, plan.approved), rejectedStream(plan.rejected))
193
196
  })(),
194
197
  ),
195
198
  )
@@ -5,29 +5,27 @@
5
5
  * results must be returned to the model. Tool execution stays explicit at
6
6
  * the recipe boundary via `Toolkit.executeAll`.
7
7
  */
8
- import { Deferred, Effect, Queue, Scope, Stream } from "effect"
8
+ import { Data, Deferred, Effect, Queue, Scope, Stream } from "effect"
9
9
  import type { FunctionCall } from "../domain/Items.js"
10
10
  import { type ToolResult, cancelled, denied } from "./Outcome.js"
11
- import type { ToolEvent } from "./ToolEvent.js"
11
+ import { ToolEvent } from "./ToolEvent.js"
12
12
 
13
13
  export type ToolCallPlan = {
14
14
  readonly approved: ReadonlyArray<FunctionCall>
15
15
  readonly rejected: ReadonlyArray<ToolResult>
16
16
  }
17
17
 
18
- export type ToolCallDecision =
19
- | { readonly _tag: "Approved"; readonly call: FunctionCall }
20
- | { readonly _tag: "Rejected"; readonly result: ToolResult }
18
+ export type ToolCallDecision = Data.TaggedEnum<{
19
+ Approved: { readonly call: FunctionCall }
20
+ Rejected: { readonly result: ToolResult }
21
+ }>
21
22
 
22
- export const approve = (call: FunctionCall): ToolCallDecision => ({
23
- _tag: "Approved",
24
- call,
25
- })
23
+ export const ToolCallDecision = Data.taggedEnum<ToolCallDecision>()
26
24
 
27
- export const reject = (result: ToolResult): ToolCallDecision => ({
28
- _tag: "Rejected",
29
- result,
30
- })
25
+ export const approve = (call: FunctionCall): ToolCallDecision => ToolCallDecision.Approved({ call })
26
+
27
+ export const reject = (result: ToolResult): ToolCallDecision =>
28
+ ToolCallDecision.Rejected({ result })
31
29
 
32
30
  export const splitToolCallDecisions = (decisions: ReadonlyArray<ToolCallDecision>): ToolCallPlan =>
33
31
  decisions.reduce<ToolCallPlan>(
@@ -38,12 +36,12 @@ export const splitToolCallDecisions = (decisions: ReadonlyArray<ToolCallDecision
38
36
  { approved: [], rejected: [] },
39
37
  )
40
38
 
41
- export const approvalRequested = (call: FunctionCall): ToolEvent => ({
42
- _tag: "ApprovalRequested",
43
- call_id: call.call_id,
44
- tool: call.name,
45
- arguments: call.arguments,
46
- })
39
+ export const approvalRequested = (call: FunctionCall): ToolEvent =>
40
+ ToolEvent.ApprovalRequested({
41
+ call_id: call.call_id,
42
+ tool: call.name,
43
+ arguments: call.arguments,
44
+ })
47
45
 
48
46
  // ---------------------------------------------------------------------------
49
47
  // Verdict queue (WebSocket-style transport).
@@ -0,0 +1,105 @@
1
+ import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
2
+ import { Effect } from "effect"
3
+ import { describe, expect, expectTypeOf, it } from "vitest"
4
+ import * as Tool from "./Tool.js"
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Minimal dual-standard schema for testing — no need to pull Zod / Valibot /
8
+ // ArkType as devDeps. This is the smallest possible object satisfying both
9
+ // `StandardSchemaV1` and `StandardJSONSchemaV1` per their published specs.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ type EmailRecipient = { readonly to: string }
13
+
14
+ const emailRecipientSchema: StandardSchemaV1<unknown, EmailRecipient> &
15
+ StandardJSONSchemaV1<unknown, EmailRecipient> = {
16
+ "~standard": {
17
+ version: 1,
18
+ vendor: "test-fixture",
19
+ validate: (value) => {
20
+ if (
21
+ typeof value === "object" &&
22
+ value !== null &&
23
+ "to" in value &&
24
+ typeof (value as { to: unknown }).to === "string"
25
+ ) {
26
+ return { value: value as EmailRecipient }
27
+ }
28
+ return { issues: [{ message: "expected { to: string }" }] }
29
+ },
30
+ jsonSchema: {
31
+ input: () => ({
32
+ type: "object",
33
+ properties: { to: { type: "string" } },
34
+ required: ["to"],
35
+ }),
36
+ output: () => ({
37
+ type: "object",
38
+ properties: { to: { type: "string" } },
39
+ required: ["to"],
40
+ }),
41
+ },
42
+ },
43
+ }
44
+
45
+ // A schema that satisfies StandardSchemaV1 only (no JSON Schema). Used to
46
+ // verify the helper's compile-time guard.
47
+ const standardOnly: StandardSchemaV1<unknown, EmailRecipient> = {
48
+ "~standard": {
49
+ version: 1,
50
+ vendor: "test-fixture",
51
+ validate: () => ({ value: { to: "" } }),
52
+ },
53
+ }
54
+
55
+ describe("Tool.fromStandardSchema", () => {
56
+ it("returns the schema (structurally) typed as ToolInputSchema<Output>", () => {
57
+ const adapted = Tool.fromStandardSchema(emailRecipientSchema)
58
+
59
+ // Same object — helper is a type-narrowing identity at runtime.
60
+ expect(adapted).toBe(emailRecipientSchema)
61
+
62
+ // Both interfaces accessible through the same `~standard` key.
63
+ const valid = adapted["~standard"].validate({ to: "hi@example.com" })
64
+ expect(valid).toEqual({ value: { to: "hi@example.com" } })
65
+
66
+ const json = adapted["~standard"].jsonSchema.input({ target: "draft-2020-12" })
67
+ expect(json).toEqual({
68
+ type: "object",
69
+ properties: { to: { type: "string" } },
70
+ required: ["to"],
71
+ })
72
+ })
73
+
74
+ it("composes with Tool.make so Input is inferred from the schema's Output", async () => {
75
+ const sendEmail = Tool.make({
76
+ name: "send_email",
77
+ description: "Send an email to a single recipient.",
78
+ inputSchema: Tool.fromStandardSchema(emailRecipientSchema),
79
+ run: ({ to }) => Effect.succeed(`queued: ${to}`),
80
+ })
81
+
82
+ // `run`'s parameter is typed as { to: string } via the schema's Output —
83
+ // this property access compiles without annotation.
84
+ const result = await Effect.runPromise(sendEmail.run({ to: "x@y.z" }))
85
+ expect(result).toBe("queued: x@y.z")
86
+ })
87
+
88
+ it("type: rejects schemas missing the Standard JSON Schema half at compile time", () => {
89
+ // @ts-expect-error — `standardOnly` lacks `jsonSchema`; helper's
90
+ // intersection constraint refuses it.
91
+ Tool.fromStandardSchema(standardOnly)
92
+ })
93
+
94
+ it("type: Output type flows through fromStandardSchema into Tool.make", () => {
95
+ const tool = Tool.make({
96
+ name: "send_email",
97
+ description: "send",
98
+ inputSchema: Tool.fromStandardSchema(emailRecipientSchema),
99
+ run: (input) => Effect.succeed(input),
100
+ })
101
+
102
+ type InputOf<T> = T extends Tool.Tool<string, infer I, unknown, never> ? I : never
103
+ expectTypeOf<InputOf<typeof tool>>().toEqualTypeOf<EmailRecipient>()
104
+ })
105
+ })
package/src/tool/Tool.ts CHANGED
@@ -36,6 +36,26 @@ export const fromEffectSchema = <S extends Schema.Codec<any, any, never, any>>(
36
36
  Schema.toStandardJSONSchemaV1(Schema.toStandardSchemaV1(schema)) as unknown as S &
37
37
  ToolInputSchema<S["Type"]>
38
38
 
39
+ /**
40
+ * Use any schema library that implements both Standard Schema (validation)
41
+ * and Standard JSON Schema (JSON Schema generation) as a `Tool.inputSchema`.
42
+ * Covers Zod 4.2+, Valibot 1.2+, and ArkType 2.1.28+ in one helper.
43
+ *
44
+ * Effect Schema doesn't implement Standard JSON Schema natively — use
45
+ * `fromEffectSchema` for those.
46
+ *
47
+ * The intersection constraint catches missing interfaces at compile time:
48
+ * a Zod v3 schema (no Standard JSON Schema) produces a precise type error
49
+ * pointing at the missing interface rather than a runtime surprise. The
50
+ * helper itself is a thin type-narrowing identity — schemas that satisfy
51
+ * both standards already structurally satisfy `ToolInputSchema`; the
52
+ * helper makes the input type inference explicit at the call site.
53
+ */
54
+ export const fromStandardSchema = <S extends StandardSchemaV1 & StandardJSONSchemaV1>(
55
+ schema: S,
56
+ ): S & ToolInputSchema<StandardSchemaV1.InferOutput<S>> =>
57
+ schema as S & ToolInputSchema<StandardSchemaV1.InferOutput<S>>
58
+
39
59
  export type Tool<Name extends string, Input, Output, R = never> = {
40
60
  readonly name: Name
41
61
  readonly description: string