@effect-uai/core 0.18.0 → 0.19.0-dev-20261110065930

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 (162) hide show
  1. package/README.md +4 -4
  2. package/dist/Content-D_Qfn8Ja.d.mts +48 -0
  3. package/dist/Content-D_Qfn8Ja.d.mts.map +1 -0
  4. package/dist/File-B923KvZ9.d.mts +46 -0
  5. package/dist/File-B923KvZ9.d.mts.map +1 -0
  6. package/dist/{Image-Cv6iBLTy.d.mts → Image-Bzc1H_UK.d.mts} +19 -8
  7. package/dist/Image-Bzc1H_UK.d.mts.map +1 -0
  8. package/dist/{Items-C7MyrYPX.d.mts → Items-Bvx8s9QH.d.mts} +109 -16
  9. package/dist/Items-Bvx8s9QH.d.mts.map +1 -0
  10. package/dist/{Loop-ChpaO-ya.d.mts → Loop-CeA0AelN.d.mts} +7 -6
  11. package/dist/Loop-CeA0AelN.d.mts.map +1 -0
  12. package/dist/Tool-Bkryw6-h.d.mts +206 -0
  13. package/dist/Tool-Bkryw6-h.d.mts.map +1 -0
  14. package/dist/ToolEvent-9inLx5cI.d.mts +22 -0
  15. package/dist/ToolEvent-9inLx5cI.d.mts.map +1 -0
  16. package/dist/ToolOutput-BNTIQgGB.d.mts +151 -0
  17. package/dist/ToolOutput-BNTIQgGB.d.mts.map +1 -0
  18. package/dist/Toolkit-D2VGeD5R.d.mts +212 -0
  19. package/dist/Toolkit-D2VGeD5R.d.mts.map +1 -0
  20. package/dist/browser/BrowserTool.d.mts +84 -22
  21. package/dist/browser/BrowserTool.d.mts.map +1 -1
  22. package/dist/browser/BrowserTool.mjs +5 -0
  23. package/dist/browser/BrowserTool.mjs.map +1 -1
  24. package/dist/capabilities/Capabilities.d.mts +1 -1
  25. package/dist/decision-model/Decision.test.mjs +1 -1
  26. package/dist/{dist-C8RmtoR8.mjs → dist-B5K-y4Be.mjs} +51 -32
  27. package/dist/dist-B5K-y4Be.mjs.map +1 -0
  28. package/dist/domain/File.d.mts +2 -0
  29. package/dist/domain/File.mjs +51 -0
  30. package/dist/domain/File.mjs.map +1 -0
  31. package/dist/domain/Image.d.mts +2 -2
  32. package/dist/domain/Image.mjs +12 -2
  33. package/dist/domain/Image.mjs.map +1 -1
  34. package/dist/domain/Items.d.mts +2 -2
  35. package/dist/domain/Items.mjs +65 -4
  36. package/dist/domain/Items.mjs.map +1 -1
  37. package/dist/domain/Realtime.d.mts +72 -490
  38. package/dist/domain/Realtime.d.mts.map +1 -1
  39. package/dist/domain/Research.d.mts +1 -1
  40. package/dist/domain/Transcript.test.mjs +1 -1
  41. package/dist/domain/Turn.d.mts +57 -262
  42. package/dist/domain/Turn.d.mts.map +1 -1
  43. package/dist/domain/Turn.mjs +1 -1
  44. package/dist/embedding-model/Embedding.d.mts +1 -1
  45. package/dist/image-generator/ImageGenerator.d.mts +10 -66
  46. package/dist/image-generator/ImageGenerator.d.mts.map +1 -1
  47. package/dist/index.d.mts +10 -9
  48. package/dist/index.mjs +7 -6
  49. package/dist/language-model/LanguageModel.d.mts +2 -2
  50. package/dist/loop/Loop.d.mts +1 -1
  51. package/dist/loop/Loop.mjs.map +1 -1
  52. package/dist/messenger/Messenger.d.mts +43 -289
  53. package/dist/messenger/Messenger.d.mts.map +1 -1
  54. package/dist/messenger/MessengerAdapter.test.mjs +1 -1
  55. package/dist/observability/Metrics.d.mts +1 -1
  56. package/dist/observability/Metrics.test.mjs +1 -1
  57. package/dist/observability/Telemetry.test.mjs +1 -1
  58. package/dist/realtime/RealtimeSession.d.mts +1 -1
  59. package/dist/realtime/RealtimeSession.test.mjs +1 -1
  60. package/dist/sandbox/Sandbox.d.mts +32 -237
  61. package/dist/sandbox/Sandbox.d.mts.map +1 -1
  62. package/dist/streaming/Multipart.mjs +2 -1
  63. package/dist/streaming/Multipart.mjs.map +1 -1
  64. package/dist/streaming/Multipart.test.mjs +28 -0
  65. package/dist/streaming/Multipart.test.mjs.map +1 -0
  66. package/dist/streaming/Settle.test.mjs +1 -1
  67. package/dist/streaming/WebSocketSession.test.mjs +1 -1
  68. package/dist/testing/MockMessenger.d.mts +19 -134
  69. package/dist/testing/MockMessenger.d.mts.map +1 -1
  70. package/dist/testing/MockProvider.d.mts +1 -1
  71. package/dist/testing/MockSandbox.d.mts +65 -446
  72. package/dist/testing/MockSandbox.d.mts.map +1 -1
  73. package/dist/tool/Content.d.mts +2 -0
  74. package/dist/tool/Content.mjs +46 -0
  75. package/dist/tool/Content.mjs.map +1 -0
  76. package/dist/tool/Content.test.d.mts +1 -0
  77. package/dist/tool/Content.test.mjs +48 -0
  78. package/dist/tool/Content.test.mjs.map +1 -0
  79. package/dist/tool/HistoryCheck.d.mts +6 -15
  80. package/dist/tool/HistoryCheck.d.mts.map +1 -1
  81. package/dist/tool/HistoryCheck.mjs +10 -24
  82. package/dist/tool/HistoryCheck.mjs.map +1 -1
  83. package/dist/tool/Tool.d.mts +2 -2
  84. package/dist/tool/Tool.mjs +55 -130
  85. package/dist/tool/Tool.mjs.map +1 -1
  86. package/dist/tool/Tool.test.mjs +220 -176
  87. package/dist/tool/Tool.test.mjs.map +1 -1
  88. package/dist/tool/ToolDecision.d.mts +164 -0
  89. package/dist/tool/ToolDecision.d.mts.map +1 -0
  90. package/dist/tool/ToolDecision.mjs +146 -0
  91. package/dist/tool/ToolDecision.mjs.map +1 -0
  92. package/dist/tool/ToolDecision.test.d.mts +1 -0
  93. package/dist/tool/ToolDecision.test.mjs +330 -0
  94. package/dist/tool/ToolDecision.test.mjs.map +1 -0
  95. package/dist/tool/ToolEvent.d.mts +2 -2
  96. package/dist/tool/ToolEvent.mjs +2 -34
  97. package/dist/tool/ToolEvent.mjs.map +1 -1
  98. package/dist/tool/ToolOutput.d.mts +2 -0
  99. package/dist/tool/ToolOutput.mjs +128 -0
  100. package/dist/tool/ToolOutput.mjs.map +1 -0
  101. package/dist/tool/ToolOutput.test.d.mts +1 -0
  102. package/dist/tool/ToolOutput.test.mjs +153 -0
  103. package/dist/tool/ToolOutput.test.mjs.map +1 -0
  104. package/dist/tool/Toolkit.d.mts +2 -2
  105. package/dist/tool/Toolkit.mjs +230 -165
  106. package/dist/tool/Toolkit.mjs.map +1 -1
  107. package/dist/tool/Toolkit.test.mjs +431 -389
  108. package/dist/tool/Toolkit.test.mjs.map +1 -1
  109. package/dist/web-read/WebReadTool.d.mts +2 -2
  110. package/dist/web-read/WebReadTool.mjs +1 -0
  111. package/dist/web-read/WebReadTool.mjs.map +1 -1
  112. package/dist/web-search/WebSearchTool.d.mts +2 -2
  113. package/dist/web-search/WebSearchTool.mjs +1 -0
  114. package/dist/web-search/WebSearchTool.mjs.map +1 -1
  115. package/package.json +17 -9
  116. package/src/browser/BrowserTool.ts +5 -0
  117. package/src/domain/File.ts +79 -0
  118. package/src/domain/Image.ts +14 -4
  119. package/src/domain/Items.ts +39 -3
  120. package/src/index.ts +3 -2
  121. package/src/loop/Loop.ts +24 -13
  122. package/src/streaming/Multipart.test.ts +26 -0
  123. package/src/streaming/Multipart.ts +5 -4
  124. package/src/tool/Content.test.ts +40 -0
  125. package/src/tool/Content.ts +38 -0
  126. package/src/tool/HistoryCheck.ts +21 -26
  127. package/src/tool/Tool.test.ts +240 -241
  128. package/src/tool/Tool.ts +203 -308
  129. package/src/tool/ToolDecision.test.ts +301 -0
  130. package/src/tool/ToolDecision.ts +360 -0
  131. package/src/tool/ToolEvent.ts +21 -41
  132. package/src/tool/ToolOutput.test.ts +132 -0
  133. package/src/tool/ToolOutput.ts +198 -0
  134. package/src/tool/Toolkit.test.ts +594 -454
  135. package/src/tool/Toolkit.ts +631 -356
  136. package/src/web-read/WebReadTool.ts +2 -1
  137. package/src/web-search/WebSearchTool.ts +2 -1
  138. package/dist/Image-Cv6iBLTy.d.mts.map +0 -1
  139. package/dist/Items-C7MyrYPX.d.mts.map +0 -1
  140. package/dist/Loop-ChpaO-ya.d.mts.map +0 -1
  141. package/dist/Tool-BF78QWtn.d.mts +0 -314
  142. package/dist/Tool-BF78QWtn.d.mts.map +0 -1
  143. package/dist/ToolEvent-Di4a723B.d.mts +0 -144
  144. package/dist/ToolEvent-Di4a723B.d.mts.map +0 -1
  145. package/dist/ToolResult-BsYQRjmW.d.mts +0 -152
  146. package/dist/ToolResult-BsYQRjmW.d.mts.map +0 -1
  147. package/dist/Toolkit-tlL0F2YR.d.mts +0 -193
  148. package/dist/Toolkit-tlL0F2YR.d.mts.map +0 -1
  149. package/dist/dist-C8RmtoR8.mjs.map +0 -1
  150. package/dist/tool/Approval.d.mts +0 -113
  151. package/dist/tool/Approval.d.mts.map +0 -1
  152. package/dist/tool/Approval.mjs +0 -80
  153. package/dist/tool/Approval.mjs.map +0 -1
  154. package/dist/tool/Approval.test.mjs +0 -371
  155. package/dist/tool/Approval.test.mjs.map +0 -1
  156. package/dist/tool/ToolResult.d.mts +0 -2
  157. package/dist/tool/ToolResult.mjs +0 -85
  158. package/dist/tool/ToolResult.mjs.map +0 -1
  159. package/src/tool/Approval.test.ts +0 -400
  160. package/src/tool/Approval.ts +0 -181
  161. package/src/tool/ToolResult.ts +0 -114
  162. /package/dist/{tool/Approval.test.d.mts → streaming/Multipart.test.d.mts} +0 -0
@@ -0,0 +1,301 @@
1
+ import { describe, expect, expectTypeOf, it } from "@effect/vitest"
2
+ import { Effect, Match, Result, Schema } from "effect"
3
+ import * as Items from "../domain/Items.js"
4
+ import * as ToolDecision from "./ToolDecision.js"
5
+ import * as HistoryCheck from "./HistoryCheck.js"
6
+ import * as Tool from "./Tool.js"
7
+ import * as Toolkit from "./Toolkit.js"
8
+ import * as ToolOutput from "./ToolOutput.js"
9
+
10
+ const call = (id: string): Items.ToolCall => ({
11
+ type: "function_call",
12
+ call_id: id,
13
+ name: "send_email",
14
+ arguments: "{}",
15
+ })
16
+
17
+ const Decisions = ToolDecision.make({
18
+ Approve: { always: Schema.optionalKey(Schema.Boolean) },
19
+ Deny: { reason: Schema.optionalKey(Schema.String) },
20
+ Respond: { output: Schema.Json },
21
+ })
22
+
23
+ describe("ToolDecision.make", () => {
24
+ it("groups waiting calls by answer, ignores answers to other calls, keeps the rest unanswered", () => {
25
+ const waiting = [call("a"), call("b"), call("c"), call("d"), call("e")]
26
+ const resolved = Decisions.resolve(waiting, [
27
+ { callId: "a", decision: Decisions.Approve({ always: true }) },
28
+ { callId: "b", decision: Decisions.Deny({ reason: "spam" }) },
29
+ { callId: "c", decision: Decisions.Respond({ output: { booked: true } }) },
30
+ { callId: "e", decision: { _tag: "Edit", subject: "hi" } },
31
+ { callId: "closed-long-ago", decision: Decisions.Approve() },
32
+ ])
33
+ const approve = {
34
+ _tag: "Approve",
35
+ call: call("a"),
36
+ decision: { _tag: "Approve", always: true },
37
+ }
38
+ const deny = { _tag: "Deny", call: call("b"), decision: { _tag: "Deny", reason: "spam" } }
39
+ const respond = {
40
+ _tag: "Respond",
41
+ call: call("c"),
42
+ decision: { _tag: "Respond", output: { booked: true } },
43
+ }
44
+ expect(resolved).toEqual({
45
+ Approve: [approve],
46
+ Deny: [deny],
47
+ Respond: [respond],
48
+ answered: [approve, deny, respond],
49
+ unanswered: [call("d")],
50
+ // An answer this definition doesn't accept is reported, not dropped.
51
+ invalid: [{ call: call("e"), decision: { _tag: "Edit", subject: "hi" } }],
52
+ })
53
+ expectTypeOf(resolved.Deny[0]!.decision).toEqualTypeOf<{
54
+ readonly _tag: "Deny"
55
+ readonly reason?: string
56
+ }>()
57
+ })
58
+
59
+ it.effect("decodes answers from the wire and rejects unknown tags", () =>
60
+ Effect.gen(function* () {
61
+ const decode = Schema.decodeUnknownEffect(Decisions.schema)
62
+ expect(yield* decode({ _tag: "Deny", reason: "no" })).toEqual(
63
+ Decisions.Deny({ reason: "no" }),
64
+ )
65
+ const error = yield* Effect.flip(decode({ _tag: "Maybe" }))
66
+ expect(error._tag).toBe("SchemaError")
67
+ }),
68
+ )
69
+
70
+ it("type: constructors need their required fields", () => {
71
+ Decisions.Approve()
72
+ // @ts-expect-error Respond requires `output`
73
+ Decisions.Respond()
74
+ })
75
+
76
+ it("a closing decision comes with the error it names, built from its fields", () => {
77
+ const { Deny } = ToolDecision.Default.resolve(
78
+ [call("a"), call("b")],
79
+ [
80
+ { callId: "a", decision: ToolDecision.Default.Deny({ reason: "not today" }) },
81
+ { callId: "b", decision: ToolDecision.Default.Deny() },
82
+ ],
83
+ )
84
+ expect(Deny.map((d) => d.error)).toEqual([
85
+ new ToolDecision.Denied({ message: "not today" }),
86
+ new ToolDecision.Denied({ message: "The user declined." }),
87
+ ])
88
+ expectTypeOf(Deny[0]!.error).toEqualTypeOf<ToolDecision.Denied>()
89
+ })
90
+
91
+ it("answered lists every answered call, tagged by its decision, for an exhaustive Match", () => {
92
+ const { answered } = ToolDecision.Default.resolve(
93
+ [call("a"), call("b")],
94
+ [
95
+ { callId: "a", decision: ToolDecision.Default.Approve() },
96
+ { callId: "b", decision: ToolDecision.Default.Deny({ reason: "no" }) },
97
+ ],
98
+ )
99
+ const handled = answered.map(
100
+ Match.type<(typeof answered)[number]>().pipe(
101
+ Match.tagsExhaustive({
102
+ Approve: ({ call }) => `run ${call.call_id}`,
103
+ Deny: ({ call, error }) => `close ${call.call_id}: ${error.message}`,
104
+ }),
105
+ ),
106
+ )
107
+ expect(handled).toEqual(["run a", "close b: no"])
108
+ })
109
+
110
+ it("type: Match over answered must handle every decision", () => {
111
+ const { answered } = ToolDecision.Default.resolve([], [])
112
+ answered.map(
113
+ Match.type<(typeof answered)[number]>().pipe(
114
+ // @ts-expect-error Deny is not handled
115
+ Match.tagsExhaustive({ Approve: () => "run" }),
116
+ ),
117
+ )
118
+ })
119
+
120
+ it("a call answered twice: the same answer counts once, two different ones are invalid", () => {
121
+ const resolved = ToolDecision.Default.resolve(
122
+ [call("a"), call("b")],
123
+ [
124
+ { callId: "a", decision: ToolDecision.Default.Approve() },
125
+ { callId: "a", decision: ToolDecision.Default.Approve() },
126
+ { callId: "b", decision: ToolDecision.Default.Approve() },
127
+ { callId: "b", decision: ToolDecision.Default.Deny() },
128
+ ],
129
+ )
130
+ expect(resolved.answered.map((e) => e.call.call_id)).toEqual(["a"])
131
+ expect(resolved.invalid).toEqual([
132
+ { call: call("b"), decision: [{ _tag: "Approve" }, { _tag: "Deny" }] },
133
+ ])
134
+ })
135
+ })
136
+
137
+ describe("ToolDecision.byTool", () => {
138
+ class Escalated extends Schema.TaggedError<Escalated>()("Escalated", {
139
+ team: Schema.Literals(["finance", "legal"]),
140
+ }) {}
141
+
142
+ const Refund = ToolDecision.make({
143
+ Approve: {},
144
+ Reduce: { amount: Schema.Number },
145
+ Escalate: ToolDecision.closesWith(
146
+ { team: Schema.Literals(["finance", "legal"]) },
147
+ ({ team }) => new Escalated({ team }),
148
+ ),
149
+ })
150
+ const Email = ToolDecision.make({
151
+ Approve: {},
152
+ Edit: { subject: Schema.String },
153
+ // A closing decision is reusable across sets.
154
+ Deny: ToolDecision.Default.variants.Deny,
155
+ })
156
+ const Decisions = ToolDecision.byTool(
157
+ { issue_refund: Refund, send_email: Email },
158
+ { fallback: ToolDecision.Default },
159
+ )
160
+
161
+ const named = (name: string, id: string): Items.ToolCall => ({
162
+ type: "function_call",
163
+ call_id: id,
164
+ name,
165
+ arguments: "{}",
166
+ })
167
+
168
+ it("resolves each call with its own tool's decisions, the rest with the fallback", () => {
169
+ const waiting = [
170
+ named("issue_refund", "r1"),
171
+ named("issue_refund", "r2"),
172
+ named("send_email", "e1"),
173
+ named("helpdesk__write_ticket", "h1"),
174
+ named("send_email", "e2"),
175
+ ]
176
+ const resolved = Decisions.resolve(waiting, [
177
+ { callId: "r1", decision: Refund.Reduce({ amount: 40 }) },
178
+ { callId: "r2", decision: Email.Edit({ subject: "a refund edited like an email" }) },
179
+ { callId: "e1", decision: Email.Edit({ subject: "Your refund" }) },
180
+ { callId: "h1", decision: ToolDecision.Default.Deny({ reason: "read only today" }) },
181
+ ])
182
+ expect(resolved.issue_refund.Reduce.map((r) => r.decision.amount)).toEqual([40])
183
+ expect(resolved.send_email.Edit.map((r) => r.call.call_id)).toEqual(["e1"])
184
+ expect(resolved.fallback.Deny.map((r) => r.error)).toEqual([
185
+ new ToolDecision.Denied({ message: "read only today" }),
186
+ ])
187
+ expect(resolved.invalid.map((i) => i.call.call_id)).toEqual(["r2"])
188
+ expect(resolved.unanswered.map((c) => c.call_id)).toEqual(["e2"])
189
+ })
190
+
191
+ it("without a fallback, a call nobody takes decisions for can't be answered", () => {
192
+ const only = ToolDecision.byTool({ issue_refund: Refund })
193
+ const resolved = only.resolve(
194
+ [named("delete_ticket", "d1"), named("delete_ticket", "d2")],
195
+ [{ callId: "d1", decision: ToolDecision.Default.Approve() }],
196
+ )
197
+ expect(resolved.invalid.map((i) => i.call.call_id)).toEqual(["d1"])
198
+ expect(resolved.unanswered.map((c) => c.call_id)).toEqual(["d2"])
199
+ })
200
+
201
+ it.effect("decodes any set's answer from the wire", () =>
202
+ Effect.gen(function* () {
203
+ const decode = Schema.decodeUnknownEffect(Decisions.schema)
204
+ expect(yield* decode({ _tag: "Reduce", amount: 5 })).toEqual(Refund.Reduce({ amount: 5 }))
205
+ expect(yield* decode({ _tag: "Edit", subject: "s" })).toEqual(Email.Edit({ subject: "s" }))
206
+ }),
207
+ )
208
+
209
+ it("type: two sets may share a tag only with the same fields", () => {
210
+ const Always = ToolDecision.make({ Approve: { always: Schema.Boolean } })
211
+ // @ts-expect-error "Approve" has different fields in issue_refund and send_email
212
+ ToolDecision.byTool({ issue_refund: Refund, send_email: Always })
213
+ // An optional extra field is a conflict too: the union would drop it.
214
+ const Maybe = ToolDecision.make({ Approve: { always: Schema.optionalKey(Schema.Boolean) } })
215
+ // @ts-expect-error "Approve" has different fields
216
+ ToolDecision.byTool({ issue_refund: Refund }, { fallback: Maybe })
217
+ // Same fields (or a reused variant) are fine.
218
+ ToolDecision.byTool(
219
+ { issue_refund: Refund, send_email: Email },
220
+ { fallback: ToolDecision.Default },
221
+ )
222
+ })
223
+
224
+ it("type: an answer takes its tool's decisions; groups and errors are typed per tool", () => {
225
+ const refund = { callId: "r1", name: "issue_refund" } as const
226
+ Decisions.answer(refund, Refund.Reduce({ amount: 1 }))
227
+ // @ts-expect-error an email decision for a refund call
228
+ Decisions.answer(refund, Email.Edit({ subject: "s" }))
229
+ Decisions.answer(
230
+ { callId: "h1", name: "helpdesk__write_ticket" },
231
+ ToolDecision.Default.Approve(),
232
+ )
233
+
234
+ type Resolved = ToolDecision.ResolvedByTool<typeof Decisions.tools, typeof ToolDecision.Default>
235
+ expectTypeOf<Resolved["issue_refund"]["Escalate"][number]["error"]>().toEqualTypeOf<Escalated>()
236
+ expectTypeOf<
237
+ Resolved["send_email"]["Edit"][number]["decision"]["subject"]
238
+ >().toEqualTypeOf<string>()
239
+ expectTypeOf<
240
+ Resolved["send_email"]["Deny"][number]["error"]
241
+ >().toEqualTypeOf<ToolDecision.Denied>()
242
+ })
243
+ })
244
+
245
+ describe("closing waiting calls", () => {
246
+ const Sent = Schema.Struct({ id: Schema.String })
247
+ const sendEmail = Tool.make({
248
+ name: "send_email",
249
+ description: "",
250
+ inputSchema: Tool.noInput,
251
+ outputSchema: Sent,
252
+ run: () => Effect.succeed({ id: "m1" }),
253
+ })
254
+ const kit = Toolkit.make(sendEmail)
255
+
256
+ it.effect("a user-supplied result reads back like the tool's own output", () =>
257
+ Effect.gen(function* () {
258
+ const answered = yield* ToolOutput.success(call("b"), Sent, { id: "u1" })
259
+ expect(yield* Toolkit.decode(kit, "send_email", answered)).toEqual(
260
+ Result.succeed({ id: "u1" }),
261
+ )
262
+ }),
263
+ )
264
+
265
+ it.effect("a denied call carries the tagged error the model sees", () =>
266
+ Effect.gen(function* () {
267
+ const denied = new ToolDecision.Denied({ message: "spam" })
268
+ expect(yield* ToolOutput.failure(call("a"), ToolDecision.Denied, denied)).toEqual({
269
+ type: "function_call_output",
270
+ call_id: "a",
271
+ output: [{ type: "input_json", value: { _tag: "Denied", message: "spam" } }],
272
+ isError: true,
273
+ })
274
+ }),
275
+ )
276
+ })
277
+
278
+ describe("HistoryCheck", () => {
279
+ it.effect("closes every unanswered call with Cancelled, so history is submittable", () =>
280
+ Effect.gen(function* () {
281
+ const history: ReadonlyArray<Items.HistoryItem> = [
282
+ Items.userText("send both"),
283
+ call("a"),
284
+ call("b"),
285
+ Items.toolCallOutput("a", "sent"),
286
+ ]
287
+ expect(HistoryCheck.findUnansweredCalls(history)).toEqual([call("b")])
288
+
289
+ const closing = yield* HistoryCheck.cancelAllPending(history, "user moved on")
290
+ expect(closing).toEqual([
291
+ {
292
+ type: "function_call_output",
293
+ call_id: "b",
294
+ output: [{ type: "input_json", value: { _tag: "Cancelled", message: "user moved on" } }],
295
+ isError: true,
296
+ },
297
+ ])
298
+ expect(HistoryCheck.isReconciled([...history, ...closing])).toBe(true)
299
+ }),
300
+ )
301
+ })
@@ -0,0 +1,360 @@
1
+ /**
2
+ * ToolDecision: define what an answer to a waiting tool call can be (approve,
3
+ * deny, edit, escalate, anything), and get the wire schema, the constructors
4
+ * and a resolver that groups waiting calls by answer. Which calls wait and what each
5
+ * answer means is app code; tools and toolkits know nothing about decisions.
6
+ */
7
+ import { Array as Arr, Equal, Function, HashMap, Option, Record, Result, Schema } from "effect"
8
+ import type { ToolCall } from "../domain/Items.js"
9
+
10
+ /** A decision's fields; context-free schemas so the wire codec needs no services. */
11
+ type Fields = Record<string, Schema.Codec<unknown, unknown, never, never>>
12
+
13
+ /** A decision that ends the call with an error, built from the decision's fields. */
14
+ export type Closing<F extends Fields, E> = {
15
+ readonly _tag: "Closing"
16
+ readonly fields: F
17
+ readonly error: (decision: Schema.Struct.Type<F>) => E
18
+ }
19
+
20
+ /**
21
+ * A decision that ends the call with an error, e.g. `Deny` with `Denied`. The
22
+ * resolver builds the error, so a decision can only end in the error it names.
23
+ */
24
+ export const closesWith = <const F extends Fields, E>(
25
+ fields: F,
26
+ error: (decision: Schema.Struct.Type<F>) => E,
27
+ ): Closing<F, E> => ({ _tag: "Closing", fields, error })
28
+
29
+ type Variants = Record<string, Fields | Closing<any, any>>
30
+
31
+ type FieldsOf<X> = X extends Closing<infer F, any> ? F : X extends Fields ? X : never
32
+
33
+ /** A decision of a definition: `{ _tag: "Approve", ...fields } | ...`. */
34
+ export type DecisionOf<V extends Variants> = {
35
+ [K in keyof V & string]: Schema.TaggedStruct<K, FieldsOf<V[K]>>["Type"]
36
+ }[keyof V & string]
37
+
38
+ type FieldsType<V extends Variants, K extends keyof V & string> = Omit<
39
+ Extract<DecisionOf<V>, { readonly _tag: K }>,
40
+ "_tag"
41
+ >
42
+
43
+ type Constructors<V extends Variants> = {
44
+ readonly [K in keyof V & string]: {} extends FieldsType<V, K>
45
+ ? (fields?: FieldsType<V, K>) => DecisionOf<V>
46
+ : (fields: FieldsType<V, K>) => DecisionOf<V>
47
+ }
48
+
49
+ /** One decision for one call, as a client sends it. */
50
+ export type Answer<D> = { readonly callId: string; readonly decision: D }
51
+
52
+ /** An answer the call's decisions don't accept (wrong tag or fields, or two different answers). */
53
+ export type Invalid = { readonly call: ToolCall; readonly decision: unknown }
54
+
55
+ /**
56
+ * One answered call, tagged like its decision so `Match.tagsExhaustive` covers
57
+ * every tag; a closing decision also carries its error.
58
+ */
59
+ export type Entry<V extends Variants, K extends keyof V & string = keyof V & string> = {
60
+ [T in K]: {
61
+ readonly _tag: T
62
+ readonly call: ToolCall
63
+ readonly decision: Extract<DecisionOf<V>, { readonly _tag: T }>
64
+ } & (V[T] extends Closing<any, infer E> ? { readonly error: E } : {})
65
+ }[K]
66
+
67
+ type Groups<V extends Variants> = {
68
+ readonly [K in keyof V & string]: ReadonlyArray<Entry<V, K>>
69
+ } & {
70
+ /** Every answered call, in order: match on `_tag` to handle each decision. */
71
+ readonly answered: ReadonlyArray<Entry<V>>
72
+ }
73
+
74
+ /** Waiting calls grouped by the tag of their answer; `unanswered` got none, `invalid` a wrong one. */
75
+ export type Resolved<V extends Variants> = Groups<V> & {
76
+ readonly unanswered: ReadonlyArray<ToolCall>
77
+ readonly invalid: ReadonlyArray<Invalid>
78
+ }
79
+
80
+ type ResolveFn<R> = {
81
+ (answers: ReadonlyArray<Answer<unknown>>): (calls: ReadonlyArray<ToolCall>) => R
82
+ (calls: ReadonlyArray<ToolCall>, answers: ReadonlyArray<Answer<unknown>>): R
83
+ }
84
+
85
+ export type Decisions<V extends Variants> = Constructors<V> & {
86
+ readonly variants: V
87
+ /** The wire form of a decision. */
88
+ readonly schema: Schema.Codec<DecisionOf<V>, unknown>
89
+ /**
90
+ * Group waiting calls by the answer each one got. Each answer is checked
91
+ * against this definition; answers for other calls are ignored.
92
+ */
93
+ readonly resolve: ResolveFn<Resolved<V>>
94
+ }
95
+
96
+ /** A call's answer: one decision, or `Conflict` when it got two different ones. */
97
+ type Answered = Result.Result<unknown, { readonly conflict: ReadonlyArray<unknown> }>
98
+
99
+ const answersById = (answers: ReadonlyArray<Answer<unknown>>): HashMap.HashMap<string, Answered> =>
100
+ HashMap.fromIterable(
101
+ Object.entries(Arr.groupBy(answers, (a) => a.callId)).map(([callId, group]) => {
102
+ const decisions = Arr.dedupeWith(
103
+ group.map((a) => a.decision),
104
+ Equal.equals,
105
+ )
106
+ const answered: Answered =
107
+ decisions.length === 1 ? Result.succeed(decisions[0]) : Result.fail({ conflict: decisions })
108
+ return [callId, answered] as const
109
+ }),
110
+ )
111
+
112
+ /**
113
+ * Define the possible answers:
114
+ *
115
+ * const RefundDecisions = ToolDecision.make({
116
+ * Approve: {},
117
+ * Reduce: { amount: Schema.Number },
118
+ * Deny: ToolDecision.closesWith({ reason: Schema.String }, ({ reason }) => new Denied({ message: reason })),
119
+ * })
120
+ * const { Approve, Reduce, Deny, unanswered, invalid } = RefundDecisions.resolve(waiting, answers)
121
+ */
122
+ export function make<const V extends Variants>(variants: V): Decisions<V>
123
+ export function make(variants: Variants): object {
124
+ const fieldsOf = (variant: Fields | Closing<Fields, unknown>): Fields =>
125
+ isClosing(variant) ? variant.fields : variant
126
+ const tags = Object.keys(variants)
127
+ const schema: Schema.Codec<{ readonly _tag: string }, unknown> = Schema.Union(
128
+ Object.entries(variants).map(([tag, variant]) => Schema.TaggedStruct(tag, fieldsOf(variant))),
129
+ )
130
+ const accepts = Schema.is(schema)
131
+ const constructors = Object.fromEntries(
132
+ tags.map((tag) => [tag, (fields: object = {}) => ({ _tag: tag, ...fields })]),
133
+ )
134
+ // An entry is tagged like its decision; a closing one brings its error, built from the fields.
135
+ const entryOf = (call: ToolCall, decision: { readonly _tag: string }) =>
136
+ Option.match(Option.fromNullishOr(variants[decision._tag]).pipe(Option.filter(isClosing)), {
137
+ onNone: () => ({ _tag: decision._tag, call, decision }),
138
+ onSome: (closing) => ({
139
+ _tag: decision._tag,
140
+ call,
141
+ decision,
142
+ error: closing.error(decision),
143
+ }),
144
+ })
145
+ const resolve = Function.dual(
146
+ 2,
147
+ (calls: ReadonlyArray<ToolCall>, answers: ReadonlyArray<Answer<unknown>>) => {
148
+ const byId = answersById(answers)
149
+ const [answeredCalls, unanswered] = Arr.partition(calls, (call) =>
150
+ Option.match(HashMap.get(byId, call.call_id), {
151
+ onNone: () => Result.fail(call),
152
+ onSome: (answer) => Result.succeed({ call, answer }),
153
+ }),
154
+ )
155
+ const [answered, invalid] = Arr.partition(answeredCalls, ({ call, answer }) =>
156
+ Result.match(answer, {
157
+ onSuccess: (decision) =>
158
+ accepts(decision)
159
+ ? Result.succeed(entryOf(call, decision))
160
+ : Result.fail({ call, decision }),
161
+ onFailure: ({ conflict }) => Result.fail({ call, decision: conflict }),
162
+ }),
163
+ )
164
+ const byTag = Arr.groupBy(answered, (entry) => entry._tag)
165
+ return {
166
+ ...Record.fromIterableWith(tags, (tag) => [tag, byTag[tag] ?? []]),
167
+ answered,
168
+ unanswered,
169
+ invalid,
170
+ }
171
+ },
172
+ )
173
+ return { ...constructors, variants, schema, resolve }
174
+ }
175
+
176
+ const isClosing = (
177
+ variant: Fields | Closing<Fields, unknown>,
178
+ ): variant is Closing<Fields, unknown> => variant["_tag"] === "Closing"
179
+
180
+ /** What the model sees for a denied call. Define your own error for more fields. */
181
+ export class Denied extends Schema.TaggedError<Denied>("@betalyra/effect-uai/Denied")("Denied", {
182
+ message: Schema.String,
183
+ }) {}
184
+
185
+ /** Approve, or deny with an optional reason; `Deny` ends the call with `Denied`. */
186
+ export const Default = make({
187
+ Approve: {},
188
+ Deny: closesWith(
189
+ { reason: Schema.optionalKey(Schema.String) },
190
+ ({ reason }) => new Denied({ message: reason ?? "The user declined." }),
191
+ ),
192
+ })
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Decisions per tool
196
+ // ---------------------------------------------------------------------------
197
+
198
+ // Any decision set, by structure: `Decisions<any>` is too narrow for a typed set's `resolve`.
199
+ type AnyDecisions = {
200
+ readonly variants: Variants
201
+ readonly schema: Schema.Codec<any, unknown>
202
+ readonly resolve: ResolveFn<any>
203
+ }
204
+ type VariantsOf<D> = D extends { readonly variants: infer V extends Variants } ? V : never
205
+ // Distributes over a union of sets, so each keeps its own tags.
206
+ type DecisionsOf<D> = D extends { readonly variants: infer V extends Variants }
207
+ ? DecisionOf<V>
208
+ : never
209
+ type Table = Record<string, AnyDecisions>
210
+
211
+ /** The decision a call of tool `N` takes: its own set, else the fallback. */
212
+ export type DecisionFor<M extends Table, F, N extends string> = N extends keyof M
213
+ ? DecisionsOf<M[N]>
214
+ : DecisionsOf<F>
215
+
216
+ /** Each tool's groups, `fallback` for the rest, then what's left unanswered or invalid. */
217
+ export type ResolvedByTool<M extends Table, F> = {
218
+ readonly [K in keyof M & string]: Groups<VariantsOf<M[K]>>
219
+ } & (F extends AnyDecisions ? { readonly fallback: Groups<VariantsOf<F>> } : {}) & {
220
+ readonly unanswered: ReadonlyArray<ToolCall>
221
+ readonly invalid: ReadonlyArray<Invalid>
222
+ }
223
+
224
+ export type ByTool<M extends Table, F> = {
225
+ readonly tools: M
226
+ /** The wire form of any answer: every set's decisions. */
227
+ readonly schema: Schema.Codec<DecisionsOf<M[keyof M]> | DecisionsOf<F>, unknown>
228
+ /** An answer to a waiting call, typed by its tool's decisions. */
229
+ readonly answer: <N extends string>(
230
+ interrupt: { readonly callId: string; readonly name: N },
231
+ decision: DecisionFor<M, F, N>,
232
+ ) => Answer<DecisionFor<M, F, N>>
233
+ /** Resolve every waiting call with its own tool's decisions. */
234
+ readonly resolve: ResolveFn<ResolvedByTool<M, F>>
235
+ }
236
+
237
+ // Exact type equality: `{}` and `{ always?: boolean }` differ (the union would drop `always`).
238
+ type Equals<X, Y> =
239
+ (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false
240
+
241
+ type TagDecision<V extends Variants, K extends string> = Extract<
242
+ DecisionOf<V>,
243
+ { readonly _tag: K }
244
+ >
245
+
246
+ // Tags two sets share with different fields.
247
+ type SharedConflict<A, B> = A extends { readonly variants: infer VA extends Variants }
248
+ ? B extends { readonly variants: infer VB extends Variants }
249
+ ? {
250
+ [K in keyof VA & keyof VB & string]: Equals<
251
+ TagDecision<VA, K>,
252
+ TagDecision<VB, K>
253
+ > extends true
254
+ ? never
255
+ : K
256
+ }[keyof VA & keyof VB & string]
257
+ : never
258
+ : never
259
+
260
+ type AllSets<M extends Table, F> = M & (F extends AnyDecisions ? { readonly fallback: F } : {})
261
+
262
+ type Conflicts<S> = {
263
+ [A in keyof S]: { [B in keyof S]: SharedConflict<S[A], S[B]> }[keyof S]
264
+ }[keyof S]
265
+
266
+ /** A tag used by two sets with different fields: the wire can't tell them apart. */
267
+ type NoConflicts<M extends Table, F> = [Conflicts<AllSets<M, F>>] extends [never]
268
+ ? unknown
269
+ : {
270
+ readonly __error: `Decision "${Conflicts<AllSets<M, F>> & string}" has different fields in two sets; rename one`
271
+ }
272
+
273
+ /**
274
+ * The decisions each tool takes, in app code:
275
+ *
276
+ * const Decisions = ToolDecision.byTool(
277
+ * { issue_refund: RefundDecisions, send_email: EmailDecisions },
278
+ * { fallback: ToolDecision.Default },
279
+ * )
280
+ *
281
+ * Sets may share a tag only with the same fields (a compile error otherwise).
282
+ * A call whose tool has no set (and no fallback) can't be answered: it stays
283
+ * `unanswered`, or lands in `invalid` when an answer arrives for it.
284
+ */
285
+ export function byTool<const M extends Table>(
286
+ tools: M & NoConflicts<M, undefined>,
287
+ ): ByTool<M, undefined>
288
+ export function byTool<const M extends Table, F extends AnyDecisions>(
289
+ tools: M & NoConflicts<M, F>,
290
+ options: { readonly fallback: F },
291
+ ): ByTool<M, F>
292
+ export function byTool(tools: Table, options?: { readonly fallback?: AnyDecisions }): object {
293
+ const fallback = options?.fallback
294
+ const sets = [...Object.values(tools), ...(fallback === undefined ? [] : [fallback])]
295
+ const schema = Schema.Union(sets.map((set) => set.schema))
296
+ const setOf = (call: ToolCall): Option.Option<AnyDecisions> =>
297
+ Option.orElse(
298
+ Option.fromNullishOr(Object.hasOwn(tools, call.name) ? tools[call.name] : undefined),
299
+ () => Option.fromNullishOr(fallback),
300
+ )
301
+ const groupsOf = (resolved: Record<string, unknown>) => {
302
+ const { unanswered: _u, invalid: _i, ...groups } = resolved
303
+ return groups
304
+ }
305
+ const resolve = Function.dual(
306
+ 2,
307
+ (calls: ReadonlyArray<ToolCall>, answers: ReadonlyArray<Answer<unknown>>) => {
308
+ const [covered, uncovered] = Arr.partition(calls, (call) =>
309
+ Option.match(setOf(call), {
310
+ onNone: () => Result.fail(call),
311
+ onSome: () => Result.succeed(call),
312
+ }),
313
+ )
314
+ const named = Object.entries(tools).map(([name, set]) => {
315
+ const resolved = set.resolve(
316
+ covered.filter((call) => call.name === name),
317
+ answers,
318
+ )
319
+ return [name, resolved] as const
320
+ })
321
+ const rest = Option.map(Option.fromNullishOr(fallback), (set) =>
322
+ set.resolve(
323
+ covered.filter((call) => !Object.hasOwn(tools, call.name)),
324
+ answers,
325
+ ),
326
+ )
327
+ const all = [...named.map(([, r]) => r), ...Option.toArray(rest)]
328
+ // A call nobody takes decisions for: an answer to it is invalid.
329
+ const byId = answersById(answers)
330
+ const [orphaned, waiting] = Arr.partition(uncovered, (call) =>
331
+ Option.match(HashMap.get(byId, call.call_id), {
332
+ onNone: () => Result.fail(call),
333
+ onSome: (answer) =>
334
+ Result.succeed({
335
+ call,
336
+ decision: Result.match(answer, {
337
+ onSuccess: Function.identity,
338
+ onFailure: (c) => c.conflict,
339
+ }),
340
+ }),
341
+ }),
342
+ )
343
+ return {
344
+ ...Object.fromEntries(named.map(([name, r]) => [name, groupsOf(r)])),
345
+ ...Option.match(rest, { onNone: () => ({}), onSome: (r) => ({ fallback: groupsOf(r) }) }),
346
+ unanswered: [...all.flatMap((r) => r.unanswered), ...waiting],
347
+ invalid: [...all.flatMap((r) => r.invalid), ...orphaned],
348
+ }
349
+ },
350
+ )
351
+ return {
352
+ tools,
353
+ schema,
354
+ answer: (interrupt: { readonly callId: string }, decision: unknown) => ({
355
+ callId: interrupt.callId,
356
+ decision,
357
+ }),
358
+ resolve,
359
+ }
360
+ }