@effect-uai/core 0.17.0 → 0.19.0-dev-20261109183959

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 (218) hide show
  1. package/README.md +5 -5
  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 +4 -4
  26. package/dist/dist-B5K-y4Be.mjs +191 -0
  27. package/dist/dist-B5K-y4Be.mjs.map +1 -0
  28. package/dist/dist-CorQTQL-.mjs +1533 -0
  29. package/dist/dist-CorQTQL-.mjs.map +1 -0
  30. package/dist/dist-DNeuh84d.mjs +7 -0
  31. package/dist/dist-DNeuh84d.mjs.map +1 -0
  32. package/dist/dist-DzeSyeYF.mjs +10253 -0
  33. package/dist/dist-DzeSyeYF.mjs.map +1 -0
  34. package/dist/domain/File.d.mts +2 -0
  35. package/dist/domain/File.mjs +51 -0
  36. package/dist/domain/File.mjs.map +1 -0
  37. package/dist/domain/Image.d.mts +2 -2
  38. package/dist/domain/Image.mjs +12 -2
  39. package/dist/domain/Image.mjs.map +1 -1
  40. package/dist/domain/Items.d.mts +2 -2
  41. package/dist/domain/Items.mjs +65 -4
  42. package/dist/domain/Items.mjs.map +1 -1
  43. package/dist/domain/Realtime.d.mts +72 -490
  44. package/dist/domain/Realtime.d.mts.map +1 -1
  45. package/dist/domain/Research.d.mts +1 -1
  46. package/dist/domain/Transcript.test.mjs +3 -3
  47. package/dist/domain/Turn.d.mts +57 -262
  48. package/dist/domain/Turn.d.mts.map +1 -1
  49. package/dist/domain/Turn.mjs +1 -1
  50. package/dist/domain/Turn.test.mjs +2 -2
  51. package/dist/embedding-model/Embedding.d.mts +1 -1
  52. package/dist/embedding-model/EmbeddingModel.test.mjs +2 -2
  53. package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -1
  54. package/dist/image-generator/ImageGenerator.d.mts +10 -66
  55. package/dist/image-generator/ImageGenerator.d.mts.map +1 -1
  56. package/dist/index.C4tKZ0yW-LuFnBtmu.mjs +7042 -0
  57. package/dist/index.C4tKZ0yW-LuFnBtmu.mjs.map +1 -0
  58. package/dist/index.d.mts +10 -9
  59. package/dist/index.mjs +7 -6
  60. package/dist/language-model/LanguageModel.d.mts +2 -2
  61. package/dist/language-model/LanguageModel.test.mjs +2 -2
  62. package/dist/loop/Loop.d.mts +1 -1
  63. package/dist/loop/Loop.mjs.map +1 -1
  64. package/dist/loop/Loop.test.mjs +3 -3
  65. package/dist/math/Probability.test.mjs +2 -2
  66. package/dist/messenger/Messenger.d.mts +43 -289
  67. package/dist/messenger/Messenger.d.mts.map +1 -1
  68. package/dist/messenger/MessengerAdapter.test.mjs +3 -3
  69. package/dist/music-generator/MusicGenerator.test.mjs +3 -3
  70. package/dist/observability/Metrics.d.mts +1 -1
  71. package/dist/observability/Metrics.test.mjs +3 -3
  72. package/dist/observability/Telemetry.d.mts +1 -1
  73. package/dist/observability/Telemetry.mjs +1 -1
  74. package/dist/observability/Telemetry.mjs.map +1 -1
  75. package/dist/observability/Telemetry.test.mjs +4 -4
  76. package/dist/observability/Telemetry.test.mjs.map +1 -1
  77. package/dist/realtime/RealtimeSession.d.mts +1 -1
  78. package/dist/realtime/RealtimeSession.test.mjs +4 -4
  79. package/dist/retry/Retry.test.mjs +2 -2
  80. package/dist/sandbox/Sandbox.d.mts +32 -237
  81. package/dist/sandbox/Sandbox.d.mts.map +1 -1
  82. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +3 -3
  83. package/dist/streaming/JSONL.test.mjs +2 -2
  84. package/dist/streaming/Multipart.d.mts +1 -1
  85. package/dist/streaming/Multipart.mjs +3 -2
  86. package/dist/streaming/Multipart.mjs.map +1 -1
  87. package/dist/streaming/Multipart.test.mjs +28 -0
  88. package/dist/streaming/Multipart.test.mjs.map +1 -0
  89. package/dist/streaming/SSE.d.mts +1 -1
  90. package/dist/streaming/SSE.mjs +2 -2
  91. package/dist/streaming/SSE.mjs.map +1 -1
  92. package/dist/streaming/SSE.test.mjs +2 -2
  93. package/dist/streaming/Settle.test.mjs +3 -3
  94. package/dist/streaming/WebSocketSession.d.mts +5 -3
  95. package/dist/streaming/WebSocketSession.d.mts.map +1 -1
  96. package/dist/streaming/WebSocketSession.mjs +22 -10
  97. package/dist/streaming/WebSocketSession.mjs.map +1 -1
  98. package/dist/streaming/WebSocketSession.test.mjs +41 -5
  99. package/dist/streaming/WebSocketSession.test.mjs.map +1 -1
  100. package/dist/structured-format/StructuredFormat.mjs.map +1 -1
  101. package/dist/structured-format/StructuredFormat.test.mjs +2 -2
  102. package/dist/testing/FakeWebSocket.d.mts +5 -1
  103. package/dist/testing/FakeWebSocket.d.mts.map +1 -1
  104. package/dist/testing/FakeWebSocket.mjs +12 -6
  105. package/dist/testing/FakeWebSocket.mjs.map +1 -1
  106. package/dist/testing/MockMessenger.d.mts +19 -134
  107. package/dist/testing/MockMessenger.d.mts.map +1 -1
  108. package/dist/testing/MockProvider.d.mts +1 -1
  109. package/dist/testing/MockSandbox.d.mts +65 -446
  110. package/dist/testing/MockSandbox.d.mts.map +1 -1
  111. package/dist/tool/Content.d.mts +2 -0
  112. package/dist/tool/Content.mjs +46 -0
  113. package/dist/tool/Content.mjs.map +1 -0
  114. package/dist/tool/Content.test.d.mts +1 -0
  115. package/dist/tool/Content.test.mjs +48 -0
  116. package/dist/tool/Content.test.mjs.map +1 -0
  117. package/dist/tool/HistoryCheck.d.mts +6 -15
  118. package/dist/tool/HistoryCheck.d.mts.map +1 -1
  119. package/dist/tool/HistoryCheck.mjs +10 -24
  120. package/dist/tool/HistoryCheck.mjs.map +1 -1
  121. package/dist/tool/Tool.d.mts +2 -2
  122. package/dist/tool/Tool.mjs +55 -130
  123. package/dist/tool/Tool.mjs.map +1 -1
  124. package/dist/tool/Tool.test.mjs +222 -178
  125. package/dist/tool/Tool.test.mjs.map +1 -1
  126. package/dist/tool/ToolDecision.d.mts +164 -0
  127. package/dist/tool/ToolDecision.d.mts.map +1 -0
  128. package/dist/tool/ToolDecision.mjs +146 -0
  129. package/dist/tool/ToolDecision.mjs.map +1 -0
  130. package/dist/tool/ToolDecision.test.d.mts +1 -0
  131. package/dist/tool/ToolDecision.test.mjs +330 -0
  132. package/dist/tool/ToolDecision.test.mjs.map +1 -0
  133. package/dist/tool/ToolEvent.d.mts +2 -2
  134. package/dist/tool/ToolEvent.mjs +2 -34
  135. package/dist/tool/ToolEvent.mjs.map +1 -1
  136. package/dist/tool/ToolOutput.d.mts +2 -0
  137. package/dist/tool/ToolOutput.mjs +128 -0
  138. package/dist/tool/ToolOutput.mjs.map +1 -0
  139. package/dist/tool/ToolOutput.test.d.mts +1 -0
  140. package/dist/tool/ToolOutput.test.mjs +153 -0
  141. package/dist/tool/ToolOutput.test.mjs.map +1 -0
  142. package/dist/tool/Toolkit.d.mts +2 -2
  143. package/dist/tool/Toolkit.mjs +230 -165
  144. package/dist/tool/Toolkit.mjs.map +1 -1
  145. package/dist/tool/Toolkit.test.mjs +433 -391
  146. package/dist/tool/Toolkit.test.mjs.map +1 -1
  147. package/dist/transcriber/Transcriber.test.mjs +3 -3
  148. package/dist/web-read/WebReadTool.d.mts +2 -2
  149. package/dist/web-read/WebReadTool.mjs +1 -0
  150. package/dist/web-read/WebReadTool.mjs.map +1 -1
  151. package/dist/web-search/WebSearchTool.d.mts +2 -2
  152. package/dist/web-search/WebSearchTool.mjs +1 -0
  153. package/dist/web-search/WebSearchTool.mjs.map +1 -1
  154. package/package.json +18 -10
  155. package/src/browser/BrowserTool.ts +5 -0
  156. package/src/domain/File.ts +79 -0
  157. package/src/domain/Image.ts +14 -4
  158. package/src/domain/Items.ts +39 -3
  159. package/src/embedding-model/EmbeddingModel.test.ts +2 -2
  160. package/src/index.ts +3 -2
  161. package/src/loop/Loop.ts +25 -14
  162. package/src/observability/Telemetry.test.ts +1 -1
  163. package/src/observability/Telemetry.ts +2 -2
  164. package/src/streaming/Multipart.test.ts +26 -0
  165. package/src/streaming/Multipart.ts +6 -5
  166. package/src/streaming/SSE.ts +2 -2
  167. package/src/streaming/WebSocketSession.test.ts +54 -2
  168. package/src/streaming/WebSocketSession.ts +27 -6
  169. package/src/structured-format/StructuredFormat.ts +1 -1
  170. package/src/testing/FakeWebSocket.ts +21 -6
  171. package/src/tool/Content.test.ts +40 -0
  172. package/src/tool/Content.ts +38 -0
  173. package/src/tool/HistoryCheck.ts +21 -26
  174. package/src/tool/Tool.test.ts +240 -241
  175. package/src/tool/Tool.ts +204 -309
  176. package/src/tool/ToolDecision.test.ts +301 -0
  177. package/src/tool/ToolDecision.ts +360 -0
  178. package/src/tool/ToolEvent.ts +21 -41
  179. package/src/tool/ToolOutput.test.ts +132 -0
  180. package/src/tool/ToolOutput.ts +198 -0
  181. package/src/tool/Toolkit.test.ts +594 -454
  182. package/src/tool/Toolkit.ts +631 -356
  183. package/src/web-read/WebReadTool.ts +2 -1
  184. package/src/web-search/WebSearchTool.ts +2 -1
  185. package/dist/Image-Cv6iBLTy.d.mts.map +0 -1
  186. package/dist/Items-C7MyrYPX.d.mts.map +0 -1
  187. package/dist/Loop-ChpaO-ya.d.mts.map +0 -1
  188. package/dist/Tool-BF78QWtn.d.mts +0 -314
  189. package/dist/Tool-BF78QWtn.d.mts.map +0 -1
  190. package/dist/ToolEvent-Di4a723B.d.mts +0 -144
  191. package/dist/ToolEvent-Di4a723B.d.mts.map +0 -1
  192. package/dist/ToolResult-BsYQRjmW.d.mts +0 -152
  193. package/dist/ToolResult-BsYQRjmW.d.mts.map +0 -1
  194. package/dist/Toolkit-tlL0F2YR.d.mts +0 -193
  195. package/dist/Toolkit-tlL0F2YR.d.mts.map +0 -1
  196. package/dist/dist-BaenoU1A.mjs +0 -145
  197. package/dist/dist-BaenoU1A.mjs.map +0 -1
  198. package/dist/dist-nohcnkD6.mjs +0 -182
  199. package/dist/dist-nohcnkD6.mjs.map +0 -1
  200. package/dist/magic-string.es-CB2hOw9v.mjs +0 -1014
  201. package/dist/magic-string.es-CB2hOw9v.mjs.map +0 -1
  202. package/dist/test.DNmyFkvJ-YPzDR0Co.mjs +0 -10254
  203. package/dist/test.DNmyFkvJ-YPzDR0Co.mjs.map +0 -1
  204. package/dist/tool/Approval.d.mts +0 -113
  205. package/dist/tool/Approval.d.mts.map +0 -1
  206. package/dist/tool/Approval.mjs +0 -80
  207. package/dist/tool/Approval.mjs.map +0 -1
  208. package/dist/tool/Approval.test.mjs +0 -371
  209. package/dist/tool/Approval.test.mjs.map +0 -1
  210. package/dist/tool/ToolResult.d.mts +0 -2
  211. package/dist/tool/ToolResult.mjs +0 -85
  212. package/dist/tool/ToolResult.mjs.map +0 -1
  213. package/dist/utils-CiH_DjFW.mjs +0 -3918
  214. package/dist/utils-CiH_DjFW.mjs.map +0 -1
  215. package/src/tool/Approval.test.ts +0 -400
  216. package/src/tool/Approval.ts +0 -181
  217. package/src/tool/ToolResult.ts +0 -114
  218. /package/dist/{tool/Approval.test.d.mts → streaming/Multipart.test.d.mts} +0 -0
@@ -1,400 +0,0 @@
1
- /**
2
- * Tests for approval planners + history-reconciliation primitives. Exercises
3
- * the full HITL + streaming-tool stack end-to-end by composing approval plans
4
- * with `run`, with the four wire-shaped scenarios:
5
- *
6
- * 1. Approval : gated calls approved → tools execute, structured Values
7
- * 2. Denial : gated calls denied → Failure(denied) results
8
- * 3. Cancellation : missing verdicts → Failure(cancelled) results
9
- * 4. Mixed + history : reconciliation via cancelAllPending
10
- *
11
- * Plus: hallucinated tool name (graceful Failure), unknown_tool kind.
12
- */
13
- import { Effect, Queue, Schema, Stream } from "effect"
14
- import { describe, expect, it } from "vitest"
15
- import * as Items from "../domain/Items.js"
16
- import { findUnansweredCalls, cancelAllPending, isReconciled } from "./HistoryCheck.js"
17
- import { type ToolResult, isFailure, isOk, toToolCallOutput } from "./ToolResult.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"
27
- import { ToolEvent, isApprovalRequested, isProgress, isOutput } from "./ToolEvent.js"
28
-
29
- // ---------------------------------------------------------------------------
30
- // Three demo tools covering the matrix:
31
- // - web_search : streaming, no approval
32
- // - bulk_email : streaming, requires approval
33
- // - delete_database : non-streaming, requires approval
34
- // ---------------------------------------------------------------------------
35
-
36
- const webSearch = makeTool({
37
- name: "web_search",
38
- description: "search",
39
- inputSchema: fromEffectSchema(Schema.Struct({ query: Schema.String })),
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
- }),
50
- })
51
-
52
- const bulkEmail = makeTool({
53
- name: "bulk_email",
54
- description: "send",
55
- inputSchema: fromEffectSchema(
56
- Schema.Struct({ recipients: Schema.Array(Schema.String), subject: Schema.String }),
57
- ),
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
- }),
65
- })
66
-
67
- const deleteDatabase = makeTool({
68
- name: "delete_database",
69
- description: "drop",
70
- inputSchema: fromEffectSchema(Schema.Struct({ name: Schema.String })),
71
- run: ({ name }) => Effect.succeed({ status: "dropped", name }),
72
- })
73
-
74
- const allTools = makeToolkit(webSearch, bulkEmail, deleteDatabase)
75
- const SENSITIVE = new Set(["bulk_email", "delete_database"])
76
- const isSensitive = (call: Items.ToolCall) => SENSITIVE.has(call.name)
77
-
78
- const fc = (call_id: string, name: string, args: unknown): Items.ToolCall => ({
79
- type: "function_call",
80
- call_id,
81
- name,
82
- arguments: JSON.stringify(args),
83
- })
84
-
85
- const calls = [
86
- fc("c1", "web_search", { query: "effect" }),
87
- fc("c2", "bulk_email", { recipients: ["a@x", "b@x"], subject: "Hi" }),
88
- fc("c3", "delete_database", { name: "prod" }),
89
- ]
90
-
91
- const resultsFrom = (collected: ReadonlyArray<ToolEvent>): ReadonlyArray<ToolResult> =>
92
- collected.filter(isOutput).map((e) => e.result)
93
-
94
- const byCallId = (results: ReadonlyArray<ToolResult>) => new Map(results.map((r) => [r.call_id, r]))
95
-
96
- const rejectedStream = (rejected: ReadonlyArray<ToolResult>) =>
97
- Stream.fromIterable(rejected.map((result) => ToolEvent.Output({ result })))
98
-
99
- const eventsFromApprovalMap = (approvals: ReadonlyMap<string, ApprovalMapEntry>) => {
100
- const plan = fromMap(isSensitive, approvals)(calls)
101
- return Stream.merge(run(allTools, plan.approved), rejectedStream(plan.rejected))
102
- }
103
-
104
- const eventsFromDecision = (decision: ApprovalDecision): Stream.Stream<ToolEvent> =>
105
- decision._tag === "Approved"
106
- ? run(allTools, [decision.call])
107
- : Stream.succeed(ToolEvent.Output({ result: decision.result }))
108
-
109
- // ---------------------------------------------------------------------------
110
- // fromMap: HTTP-style scenarios
111
- // ---------------------------------------------------------------------------
112
-
113
- describe("fromMap + run", () => {
114
- it("approval: all gated approved → tools execute, structured Values", async () => {
115
- const approvals = new Map<string, ApprovalMapEntry>([
116
- ["c2", { decision: "approve" }],
117
- ["c3", { decision: "approve" }],
118
- ])
119
- const collected = await Effect.runPromise(Stream.runCollect(eventsFromApprovalMap(approvals)))
120
- const by = byCallId(resultsFrom(collected))
121
- expect(by.get("c1")).toMatchObject({ _tag: "Ok", value: { count: 3 } })
122
- expect(by.get("c2")).toMatchObject({
123
- _tag: "Ok",
124
- value: { status: "sent", delivered: 2 },
125
- })
126
- expect(by.get("c3")).toMatchObject({
127
- _tag: "Ok",
128
- value: { status: "dropped", name: "prod" },
129
- })
130
-
131
- // No ApprovalRequested events from the pure HTTP flow.
132
- expect(collected.filter(isApprovalRequested)).toHaveLength(0)
133
- })
134
-
135
- it("denial: gated denied → Failure(denied), no execution", async () => {
136
- const approvals = new Map<string, ApprovalMapEntry>([
137
- ["c2", { decision: "deny", reason: "spam concern" }],
138
- ["c3", { decision: "deny", reason: "prod is sacred" }],
139
- ])
140
- const collected = await Effect.runPromise(Stream.runCollect(eventsFromApprovalMap(approvals)))
141
-
142
- // bulk_email never ran.
143
- expect(collected.filter(isProgress).filter((e) => e.tool === "bulk_email")).toHaveLength(0)
144
-
145
- const by = byCallId(resultsFrom(collected))
146
- expect(by.get("c2")).toMatchObject({
147
- _tag: "Failure",
148
- kind: "denied",
149
- reason: "spam concern",
150
- })
151
- expect(by.get("c3")).toMatchObject({
152
- _tag: "Failure",
153
- kind: "denied",
154
- reason: "prod is sacred",
155
- })
156
- })
157
-
158
- it("cancellation: missing verdicts → Failure(cancelled)", async () => {
159
- const collected = await Effect.runPromise(Stream.runCollect(eventsFromApprovalMap(new Map())))
160
- const by = byCallId(resultsFrom(collected))
161
- expect(by.get("c1")).toMatchObject({ _tag: "Ok", value: { count: 3 } })
162
- expect(by.get("c2")).toMatchObject({ _tag: "Failure", kind: "cancelled" })
163
- expect(by.get("c3")).toMatchObject({ _tag: "Failure", kind: "cancelled" })
164
- })
165
-
166
- it("mixed: approve + deny + omit → all three kinds", async () => {
167
- const approvals = new Map<string, ApprovalMapEntry>([
168
- ["c2", { decision: "approve" }],
169
- // c3 omitted → cancelled
170
- ])
171
- const collected = await Effect.runPromise(Stream.runCollect(eventsFromApprovalMap(approvals)))
172
- const by = byCallId(resultsFrom(collected))
173
- expect(by.get("c1")).toMatchObject({ _tag: "Ok", value: { count: 3 } })
174
- expect(by.get("c2")).toMatchObject({ _tag: "Ok", value: { status: "sent" } })
175
- expect(by.get("c3")).toMatchObject({ _tag: "Failure", kind: "cancelled" })
176
- })
177
- })
178
-
179
- // ---------------------------------------------------------------------------
180
- // Graceful degradation: hallucinated tool name doesn't kill the turn.
181
- // ---------------------------------------------------------------------------
182
-
183
- describe("run: graceful degradation", () => {
184
- it("unknown tool name → Failure(unknown_tool); other calls still execute", async () => {
185
- const callsWithBogus = [
186
- fc("c1", "web_search", { query: "x" }),
187
- fc("c2", "does_not_exist", {}),
188
- fc("c3", "delete_database", { name: "prod" }),
189
- ]
190
- const collected = await Effect.runPromise(
191
- Stream.runCollect(
192
- (() => {
193
- const plan = fromMap(
194
- isSensitive,
195
- new Map([["c3", { decision: "approve" }]]),
196
- )(callsWithBogus)
197
- return Stream.merge(run(allTools, plan.approved), rejectedStream(plan.rejected))
198
- })(),
199
- ),
200
- )
201
- const by = byCallId(resultsFrom(collected))
202
- expect(by.get("c1")).toMatchObject({ _tag: "Ok" })
203
- expect(by.get("c2")).toMatchObject({ _tag: "Failure", kind: "unknown_tool" })
204
- expect(by.get("c3")).toMatchObject({ _tag: "Ok", value: { status: "dropped" } })
205
- })
206
- })
207
-
208
- // ---------------------------------------------------------------------------
209
- // fromQueue: WebSocket-style scenarios
210
- // ---------------------------------------------------------------------------
211
-
212
- describe("fromQueue + run", () => {
213
- it("queue-driven: approve + deny resolve correctly with ApprovalRequested events", async () => {
214
- const collected = await Effect.runPromise(
215
- Effect.gen(function* () {
216
- const verdicts = yield* Queue.unbounded<{
217
- readonly call_id: string
218
- readonly decision: "approve" | "deny"
219
- readonly reason?: string
220
- }>()
221
- yield* Queue.offer(verdicts, { call_id: "c2", decision: "approve" })
222
- yield* Queue.offer(verdicts, {
223
- call_id: "c3",
224
- decision: "deny",
225
- reason: "too risky",
226
- })
227
-
228
- // Stream.unwrap supplies the Scope for fromQueue's router.
229
- const events = Stream.unwrap(
230
- Effect.gen(function* () {
231
- const { approved, decisions, approvalRequests } = yield* fromQueue(
232
- isSensitive,
233
- verdicts,
234
- )(calls)
235
- return Stream.merge(
236
- approvalRequests,
237
- Stream.merge(
238
- run(allTools, approved),
239
- decisions.pipe(Stream.flatMap(eventsFromDecision)),
240
- ),
241
- )
242
- }),
243
- )
244
- return yield* Stream.runCollect(events)
245
- }),
246
- )
247
-
248
- expect(collected.filter(isApprovalRequested)).toHaveLength(2)
249
-
250
- const by = byCallId(resultsFrom(collected))
251
- expect(by.get("c2")).toMatchObject({ _tag: "Ok", value: { status: "sent" } })
252
- expect(by.get("c3")).toMatchObject({
253
- _tag: "Failure",
254
- kind: "denied",
255
- reason: "too risky",
256
- })
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
- })
302
- })
303
-
304
- // ---------------------------------------------------------------------------
305
- // History reconciliation
306
- // ---------------------------------------------------------------------------
307
-
308
- describe("findUnansweredCalls / cancelAllPending / isReconciled", () => {
309
- const orphan = fc("c99", "delete_database", { name: "prod" })
310
- const answered = fc("c98", "web_search", { query: "x" })
311
- const answeredOutput = Items.toolCallOutput("c98", JSON.stringify({ count: 0 }))
312
-
313
- it("findUnansweredCalls returns only orphans", () => {
314
- const history = [Items.userText("hi"), answered, orphan, answeredOutput]
315
- const unanswered = findUnansweredCalls(history)
316
- expect(unanswered).toHaveLength(1)
317
- expect(unanswered[0]!.call_id).toBe("c99")
318
- })
319
-
320
- it("isReconciled is false when orphans exist, true otherwise", () => {
321
- const stale = [Items.userText("hi"), orphan]
322
- expect(isReconciled(stale)).toBe(false)
323
- const reconciled = [...stale, ...cancelAllPending(stale).map(toToolCallOutput)]
324
- expect(isReconciled(reconciled)).toBe(true)
325
- })
326
-
327
- it("cancelAllPending synthesizes one Failure(cancelled) per orphan", () => {
328
- const history = [Items.userText("hi"), answered, orphan, answeredOutput]
329
- const closures = cancelAllPending(history, "user moved on")
330
- expect(closures).toHaveLength(1)
331
- const c = closures[0]!
332
- expect(isFailure(c)).toBe(true)
333
- expect(c).toMatchObject({
334
- _tag: "Failure",
335
- call_id: "c99",
336
- kind: "cancelled",
337
- reason: "user moved on",
338
- })
339
- })
340
-
341
- it("follow-up: map closures to ToolCallOutput before appending new user message", () => {
342
- const stale = [Items.userText("first request"), orphan]
343
- const closures = cancelAllPending(stale, "user redirected")
344
- const reconciled = [...stale, ...closures.map(toToolCallOutput), Items.userText("never mind")]
345
- expect(findUnansweredCalls(reconciled)).toHaveLength(0)
346
- })
347
- })
348
-
349
- // ---------------------------------------------------------------------------
350
- // Wire conversion
351
- // ---------------------------------------------------------------------------
352
-
353
- describe("toToolCallOutput", () => {
354
- it("round-trips a Value result", () => {
355
- const r: ToolResult = {
356
- _tag: "Ok",
357
- call_id: "c1",
358
- tool: "web_search",
359
- value: { count: 3 },
360
- }
361
- const out = toToolCallOutput(r)
362
- expect(out.call_id).toBe("c1")
363
- expect(JSON.parse(out.output)).toEqual({ count: 3 })
364
- })
365
-
366
- it("renders a Failure with reason as a nested error object", () => {
367
- const r: ToolResult = {
368
- _tag: "Failure",
369
- call_id: "c2",
370
- tool: "bulk_email",
371
- kind: "denied",
372
- reason: "spam concern",
373
- }
374
- const out = toToolCallOutput(r)
375
- expect(JSON.parse(out.output)).toEqual({ error: { kind: "denied", message: "spam concern" } })
376
- })
377
-
378
- it("renders a Failure without reason (omits message)", () => {
379
- const r: ToolResult = {
380
- _tag: "Failure",
381
- call_id: "c3",
382
- tool: "delete_database",
383
- kind: "cancelled",
384
- }
385
- const out = toToolCallOutput(r)
386
- expect(JSON.parse(out.output)).toEqual({ error: { kind: "cancelled" } })
387
- })
388
- })
389
-
390
- // ---------------------------------------------------------------------------
391
- // run
392
- // ---------------------------------------------------------------------------
393
-
394
- describe("run", () => {
395
- it("runs all calls passed to it", async () => {
396
- const collected = await Effect.runPromise(Stream.runCollect(run(allTools, calls)))
397
- expect(collected.filter(isOutput)).toHaveLength(3)
398
- expect(collected.filter(isOutput).every((e) => isOk(e.result))).toBe(true)
399
- })
400
- })
@@ -1,181 +0,0 @@
1
- /**
2
- * Approval helpers for the two transport flavors.
3
- *
4
- * These helpers only decide which calls are approved and which synthetic
5
- * results must be returned to the model. Tool execution stays explicit at
6
- * the recipe boundary via `Toolkit.run`.
7
- */
8
- import { Data, Deferred, type Duration, Effect, Queue, Ref, Scope, Stream } from "effect"
9
- import type { ToolCall } from "../domain/Items.js"
10
- import { type ToolResult, cancelled, denied } from "./ToolResult.js"
11
- import { ToolEvent } from "./ToolEvent.js"
12
-
13
- export type ToolCallPlan = {
14
- readonly approved: ReadonlyArray<ToolCall>
15
- readonly rejected: ReadonlyArray<ToolResult>
16
- }
17
-
18
- export type ApprovalDecision = Data.TaggedEnum<{
19
- Approved: { readonly call: ToolCall }
20
- Rejected: { readonly result: ToolResult }
21
- }>
22
-
23
- export const ApprovalDecision = Data.taggedEnum<ApprovalDecision>()
24
-
25
- export const approve = (call: ToolCall): ApprovalDecision => ApprovalDecision.Approved({ call })
26
-
27
- export const reject = (result: ToolResult): ApprovalDecision =>
28
- ApprovalDecision.Rejected({ result })
29
-
30
- export const splitApprovalDecisions = (decisions: ReadonlyArray<ApprovalDecision>): ToolCallPlan =>
31
- decisions.reduce<ToolCallPlan>(
32
- (acc, decision) =>
33
- decision._tag === "Approved"
34
- ? { ...acc, approved: [...acc.approved, decision.call] }
35
- : { ...acc, rejected: [...acc.rejected, decision.result] },
36
- { approved: [], rejected: [] },
37
- )
38
-
39
- export const approvalRequested = (call: ToolCall): ToolEvent =>
40
- ToolEvent.ApprovalRequested({
41
- call_id: call.call_id,
42
- tool: call.name,
43
- arguments: call.arguments,
44
- })
45
-
46
- // ---------------------------------------------------------------------------
47
- // Verdict queue (WebSocket-style transport).
48
- // ---------------------------------------------------------------------------
49
-
50
- export type Verdict = {
51
- readonly call_id: string
52
- readonly decision: "approve" | "deny"
53
- readonly reason?: string
54
- }
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
-
65
- /**
66
- * Queue-backed approval planner. Safe calls are returned immediately in
67
- * `approved`; gated calls emit `ApprovalRequested` events and later produce
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.
73
- */
74
- export const fromQueue =
75
- (
76
- predicate: (call: ToolCall) => boolean,
77
- verdicts: Queue.Dequeue<Verdict>,
78
- options?: FromQueueOptions,
79
- ) =>
80
- (
81
- calls: ReadonlyArray<ToolCall>,
82
- ): Effect.Effect<
83
- {
84
- readonly approved: ReadonlyArray<ToolCall>
85
- readonly decisions: Stream.Stream<ApprovalDecision>
86
- readonly approvalRequests: Stream.Stream<ToolEvent>
87
- },
88
- never,
89
- Scope.Scope
90
- > =>
91
- Effect.gen(function* () {
92
- const gated = calls.filter(predicate)
93
- const approved = calls.filter((call) => !predicate(call))
94
-
95
- const entries = yield* Effect.forEach(gated, (call) =>
96
- Deferred.make<ApprovalDecision>().pipe(Effect.map((d) => [call.call_id, d] as const)),
97
- )
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
- ),
133
- ),
134
- )
135
-
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"))),
144
- ),
145
- ),
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
- }),
155
- )
156
-
157
- const approvalRequests = Stream.fromIterable<ToolEvent>(gated.map(approvalRequested))
158
-
159
- return { approved, decisions, approvalRequests }
160
- })
161
-
162
- // ---------------------------------------------------------------------------
163
- // Approval map (HTTP-style transport). Verdicts arrive synchronously
164
- // bundled in the request payload. Missing entries → cancelled.
165
- // ---------------------------------------------------------------------------
166
-
167
- export type ApprovalMapEntry =
168
- | { readonly decision: "approve" }
169
- | { readonly decision: "deny"; readonly reason?: string }
170
-
171
- export const fromMap =
172
- (predicate: (call: ToolCall) => boolean, approvals: ReadonlyMap<string, ApprovalMapEntry>) =>
173
- (calls: ReadonlyArray<ToolCall>): ToolCallPlan =>
174
- splitApprovalDecisions(
175
- calls.map((call) => {
176
- if (!predicate(call)) return approve(call)
177
- const v = approvals.get(call.call_id)
178
- if (v === undefined) return reject(cancelled(call))
179
- return v.decision === "approve" ? approve(call) : reject(denied(call, v.reason))
180
- }),
181
- )
@@ -1,114 +0,0 @@
1
- /**
2
- * Post-execution and synthetic tool results.
3
- *
4
- * - Executed tools emit ToolResult.Ok.
5
- * - Approval/cancellation policy emits synthetic ToolResult.Failure.
6
- *
7
- * Wire conversion stays at the recipe boundary via `toToolCallOutput`
8
- * so recipes can inspect, redact, or audit values before serialization.
9
- *
10
- * `output` and `reason` are `string`, not `unknown`: the wire wants strings,
11
- * and `unknown` would invite non-serializable values (Date, Map, BigInt,
12
- * fn). Recipes that want structured detail JSON.stringify themselves.
13
- */
14
- import { Data } from "effect"
15
- import type { ToolCall, ToolCallOutput } from "../domain/Items.js"
16
- import { toolCallOutput } from "../domain/Items.js"
17
-
18
- // ---------------------------------------------------------------------------
19
- // ToolResult
20
- // ---------------------------------------------------------------------------
21
-
22
- export type ToolResult = Data.TaggedEnum<{
23
- Ok: {
24
- readonly call_id: string
25
- readonly tool: string
26
- readonly value: unknown
27
- }
28
- Failure: {
29
- readonly call_id: string
30
- readonly tool: string
31
- readonly kind: string
32
- readonly reason?: string
33
- }
34
- }>
35
-
36
- /**
37
- * Namespace of constructors, type guards, and matchers for `ToolResult`,
38
- * provided by `Data.taggedEnum`. Use `ToolResult.$is("Ok")` for type
39
- * narrowing and `ToolResult.$match({ Ok, Failure })` for exhaustive
40
- * pattern matching. Synthetic-result helpers (`denied`, `cancelled`,
41
- * `executionError`, `failed`) below are kinder constructors than the
42
- * raw `ToolResult.Failure(...)`.
43
- */
44
- export const ToolResult = Data.taggedEnum<ToolResult>()
45
-
46
- export const isOk = ToolResult.$is("Ok")
47
- export const isFailure = ToolResult.$is("Failure")
48
-
49
- // Synthesizers. `denied` and `cancelled` are operationally distinct;
50
- // anything else is just a recipe-chosen `kind` via `failed`.
51
- // ---------------------------------------------------------------------------
52
-
53
- export const failed = (call: ToolCall, kind: string, reason?: string): ToolResult =>
54
- ToolResult.Failure({
55
- call_id: call.call_id,
56
- tool: call.name,
57
- kind,
58
- ...(reason !== undefined ? { reason } : {}),
59
- })
60
-
61
- /** Explicit user/policy rejection. */
62
- export const denied = (call: ToolCall, reason?: string): ToolResult =>
63
- failed(call, "denied", reason)
64
-
65
- /** Implicit non-answer (follow-up, inactivity, abort). */
66
- export const cancelled = (call: ToolCall, reason?: string): ToolResult =>
67
- failed(call, "cancelled", reason)
68
-
69
- /** Tool's own execution failed (JSON parse error, runtime crash). */
70
- export const executionError = (call: ToolCall, reason: string): ToolResult =>
71
- failed(call, "execution_error", reason)
72
-
73
- /**
74
- * The model called a tool that is visible to it but has no local executor — a
75
- * provider-hosted, signal, or interaction tool routed through `Toolkit.run`.
76
- * Distinct from `unknown_tool` (no such tool at all): the loop is expected to
77
- * intercept these kinds before execution, so this kind flags a missing handler.
78
- */
79
- export const nonLocalTool = (call: ToolCall, reason?: string): ToolResult =>
80
- failed(
81
- call,
82
- "non_local_tool",
83
- reason ?? `Tool "${call.name}" is visible to the model but has no local executor`,
84
- )
85
-
86
- /**
87
- * The model's `arguments` failed the tool's input schema. Distinct kind from
88
- * `execution_error` so recipes can tell a contract violation apart from a
89
- * runtime crash.
90
- */
91
- export const validationError = (call: ToolCall, reason?: string): ToolResult =>
92
- failed(call, "input_validation_error", reason ?? "Tool input failed schema validation")
93
-
94
- // ---------------------------------------------------------------------------
95
- // Wire conversion - the one place structured → string happens.
96
- // ---------------------------------------------------------------------------
97
-
98
- /**
99
- * Serialize a success value for the wire. A `string` passes through raw
100
- * (models read plain text best; JSON.stringify would quote and escape it);
101
- * `undefined` becomes `"null"`; anything else is JSON.
102
- */
103
- export const serializeValue = (value: unknown): string =>
104
- typeof value === "string" ? value : JSON.stringify(value ?? null)
105
-
106
- /** The one failure shape the model sees: `{"error":{"kind","message"}}`. */
107
- const failureBody = (kind: string, message?: string): string =>
108
- JSON.stringify({ error: message !== undefined ? { kind, message } : { kind } })
109
-
110
- export const toToolCallOutput = (r: ToolResult): ToolCallOutput =>
111
- ToolResult.$match(r, {
112
- Ok: (v) => toolCallOutput(v.call_id, serializeValue(v.value)),
113
- Failure: (f) => toolCallOutput(f.call_id, failureBody(f.kind, f.reason)),
114
- })