@effect-uai/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -0
  3. package/dist/AiError-CqmYjXyx.d.mts +110 -0
  4. package/dist/AiError-CqmYjXyx.d.mts.map +1 -0
  5. package/dist/Items-D1C2686t.d.mts +372 -0
  6. package/dist/Items-D1C2686t.d.mts.map +1 -0
  7. package/dist/Loop-CzSJo1h8.d.mts +87 -0
  8. package/dist/Loop-CzSJo1h8.d.mts.map +1 -0
  9. package/dist/Outcome-C2JYknCu.d.mts +40 -0
  10. package/dist/Outcome-C2JYknCu.d.mts.map +1 -0
  11. package/dist/StructuredFormat-B5ueioNr.d.mts +88 -0
  12. package/dist/StructuredFormat-B5ueioNr.d.mts.map +1 -0
  13. package/dist/Tool-5wxOCuOh.d.mts +86 -0
  14. package/dist/Tool-5wxOCuOh.d.mts.map +1 -0
  15. package/dist/ToolEvent-B2N10hr3.d.mts +29 -0
  16. package/dist/ToolEvent-B2N10hr3.d.mts.map +1 -0
  17. package/dist/Turn-rlTfuHaQ.d.mts +211 -0
  18. package/dist/Turn-rlTfuHaQ.d.mts.map +1 -0
  19. package/dist/chunk-CfYAbeIz.mjs +13 -0
  20. package/dist/domain/AiError.d.mts +2 -0
  21. package/dist/domain/AiError.mjs +40 -0
  22. package/dist/domain/AiError.mjs.map +1 -0
  23. package/dist/domain/Items.d.mts +2 -0
  24. package/dist/domain/Items.mjs +238 -0
  25. package/dist/domain/Items.mjs.map +1 -0
  26. package/dist/domain/Turn.d.mts +2 -0
  27. package/dist/domain/Turn.mjs +82 -0
  28. package/dist/domain/Turn.mjs.map +1 -0
  29. package/dist/index.d.mts +14 -0
  30. package/dist/index.mjs +14 -0
  31. package/dist/language-model/LanguageModel.d.mts +60 -0
  32. package/dist/language-model/LanguageModel.d.mts.map +1 -0
  33. package/dist/language-model/LanguageModel.mjs +33 -0
  34. package/dist/language-model/LanguageModel.mjs.map +1 -0
  35. package/dist/loop/Loop.d.mts +2 -0
  36. package/dist/loop/Loop.mjs +172 -0
  37. package/dist/loop/Loop.mjs.map +1 -0
  38. package/dist/match/Match.d.mts +16 -0
  39. package/dist/match/Match.d.mts.map +1 -0
  40. package/dist/match/Match.mjs +15 -0
  41. package/dist/match/Match.mjs.map +1 -0
  42. package/dist/observability/Metrics.d.mts +45 -0
  43. package/dist/observability/Metrics.d.mts.map +1 -0
  44. package/dist/observability/Metrics.mjs +52 -0
  45. package/dist/observability/Metrics.mjs.map +1 -0
  46. package/dist/streaming/JSONL.d.mts +34 -0
  47. package/dist/streaming/JSONL.d.mts.map +1 -0
  48. package/dist/streaming/JSONL.mjs +51 -0
  49. package/dist/streaming/JSONL.mjs.map +1 -0
  50. package/dist/streaming/Lines.d.mts +27 -0
  51. package/dist/streaming/Lines.d.mts.map +1 -0
  52. package/dist/streaming/Lines.mjs +32 -0
  53. package/dist/streaming/Lines.mjs.map +1 -0
  54. package/dist/streaming/SSE.d.mts +31 -0
  55. package/dist/streaming/SSE.d.mts.map +1 -0
  56. package/dist/streaming/SSE.mjs +58 -0
  57. package/dist/streaming/SSE.mjs.map +1 -0
  58. package/dist/structured-format/StructuredFormat.d.mts +2 -0
  59. package/dist/structured-format/StructuredFormat.mjs +68 -0
  60. package/dist/structured-format/StructuredFormat.mjs.map +1 -0
  61. package/dist/testing/MockProvider.d.mts +48 -0
  62. package/dist/testing/MockProvider.d.mts.map +1 -0
  63. package/dist/testing/MockProvider.mjs +95 -0
  64. package/dist/testing/MockProvider.mjs.map +1 -0
  65. package/dist/tool/HistoryCheck.d.mts +24 -0
  66. package/dist/tool/HistoryCheck.d.mts.map +1 -0
  67. package/dist/tool/HistoryCheck.mjs +39 -0
  68. package/dist/tool/HistoryCheck.mjs.map +1 -0
  69. package/dist/tool/Outcome.d.mts +2 -0
  70. package/dist/tool/Outcome.mjs +45 -0
  71. package/dist/tool/Outcome.mjs.map +1 -0
  72. package/dist/tool/Resolvers.d.mts +44 -0
  73. package/dist/tool/Resolvers.d.mts.map +1 -0
  74. package/dist/tool/Resolvers.mjs +67 -0
  75. package/dist/tool/Resolvers.mjs.map +1 -0
  76. package/dist/tool/Tool.d.mts +2 -0
  77. package/dist/tool/Tool.mjs +79 -0
  78. package/dist/tool/Tool.mjs.map +1 -0
  79. package/dist/tool/ToolEvent.d.mts +2 -0
  80. package/dist/tool/ToolEvent.mjs +8 -0
  81. package/dist/tool/ToolEvent.mjs.map +1 -0
  82. package/dist/tool/Toolkit.d.mts +34 -0
  83. package/dist/tool/Toolkit.d.mts.map +1 -0
  84. package/dist/tool/Toolkit.mjs +105 -0
  85. package/dist/tool/Toolkit.mjs.map +1 -0
  86. package/package.json +127 -0
  87. package/src/domain/AiError.ts +93 -0
  88. package/src/domain/Items.ts +260 -0
  89. package/src/domain/Turn.ts +174 -0
  90. package/src/index.ts +13 -0
  91. package/src/language-model/LanguageModel.ts +73 -0
  92. package/src/loop/Loop.test.ts +412 -0
  93. package/src/loop/Loop.ts +295 -0
  94. package/src/match/Match.ts +9 -0
  95. package/src/observability/Metrics.ts +87 -0
  96. package/src/streaming/JSONL.test.ts +85 -0
  97. package/src/streaming/JSONL.ts +96 -0
  98. package/src/streaming/Lines.ts +34 -0
  99. package/src/streaming/SSE.test.ts +72 -0
  100. package/src/streaming/SSE.ts +114 -0
  101. package/src/structured-format/StructuredFormat.ts +160 -0
  102. package/src/testing/MockProvider.ts +161 -0
  103. package/src/tool/HistoryCheck.ts +49 -0
  104. package/src/tool/Outcome.ts +101 -0
  105. package/src/tool/Resolvers.test.ts +426 -0
  106. package/src/tool/Resolvers.ts +166 -0
  107. package/src/tool/Tool.ts +150 -0
  108. package/src/tool/ToolEvent.ts +37 -0
  109. package/src/tool/Toolkit.test.ts +45 -0
  110. package/src/tool/Toolkit.ts +228 -0
@@ -0,0 +1,426 @@
1
+ /**
2
+ * Tests for the resolver-based executor + resolvers + history-reconciliation
3
+ * primitives. Exercises the full HITL + streaming-tool stack end-to-end via
4
+ * `executeAllWithResolver`, 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 {
18
+ type ToolResult,
19
+ isFailure,
20
+ isValue,
21
+ toFunctionCallOutput,
22
+ } from "./Outcome.js"
23
+ import {
24
+ type ApprovalMapEntry,
25
+ fromApprovalMap,
26
+ fromVerdictQueue,
27
+ withFallback,
28
+ withPermissions,
29
+ } from "./Resolvers.js"
30
+ import { fromEffectSchema, make as makeTool, streaming } from "./Tool.js"
31
+ import { executeAll, executeAllWithResolver } from "./Toolkit.js"
32
+ import {
33
+ type ToolEvent,
34
+ isApprovalRequested,
35
+ isIntermediate,
36
+ isOutput,
37
+ } from "./ToolEvent.js"
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Three demo tools covering the matrix:
41
+ // - web_search : streaming, no approval
42
+ // - bulk_email : streaming, requires approval
43
+ // - delete_database : non-streaming, requires approval
44
+ // ---------------------------------------------------------------------------
45
+
46
+ const webSearch = streaming({
47
+ name: "web_search",
48
+ description: "search",
49
+ inputSchema: fromEffectSchema(Schema.Struct({ query: Schema.String })),
50
+ run: ({ query }) =>
51
+ Stream.fromIterable([
52
+ { url: "a", title: `${query} 1` },
53
+ { url: "b", title: `${query} 2` },
54
+ { url: "c", title: `${query} 3` },
55
+ ]),
56
+ finalize: (hits) => ({ count: hits.length }),
57
+ })
58
+
59
+ const bulkEmail = streaming({
60
+ name: "bulk_email",
61
+ description: "send",
62
+ inputSchema: fromEffectSchema(
63
+ Schema.Struct({ recipients: Schema.Array(Schema.String), subject: Schema.String }),
64
+ ),
65
+ run: ({ recipients }) =>
66
+ Stream.fromIterable(
67
+ recipients.map((_, i) => ({
68
+ type: "progress" as const,
69
+ sent: i + 1,
70
+ total: recipients.length,
71
+ })),
72
+ ),
73
+ finalize: (events) => ({ status: "sent" as const, delivered: events.length }),
74
+ })
75
+
76
+ const deleteDatabase = makeTool({
77
+ name: "delete_database",
78
+ description: "drop",
79
+ inputSchema: fromEffectSchema(Schema.Struct({ name: Schema.String })),
80
+ run: ({ name }) => Effect.succeed({ status: "dropped", name }),
81
+ })
82
+
83
+ const allTools = [webSearch, bulkEmail, deleteDatabase]
84
+ const SENSITIVE = new Set(["bulk_email", "delete_database"])
85
+ const isSensitive = (call: Items.FunctionCall) => SENSITIVE.has(call.name)
86
+
87
+ const fc = (call_id: string, name: string, args: unknown): Items.FunctionCall => ({
88
+ type: "function_call",
89
+ call_id,
90
+ name,
91
+ arguments: JSON.stringify(args),
92
+ })
93
+
94
+ const calls = [
95
+ fc("c1", "web_search", { query: "effect" }),
96
+ fc("c2", "bulk_email", { recipients: ["a@x", "b@x"], subject: "Hi" }),
97
+ fc("c3", "delete_database", { name: "prod" }),
98
+ ]
99
+
100
+ const resultsFrom = (
101
+ collected: ReadonlyArray<ToolEvent>,
102
+ ): ReadonlyArray<ToolResult> => collected.filter(isOutput).map((e) => e.result)
103
+
104
+ const byCallId = (results: ReadonlyArray<ToolResult>) =>
105
+ new Map(results.map((r) => [r.call_id, r]))
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // fromApprovalMap: HTTP-style scenarios
109
+ // ---------------------------------------------------------------------------
110
+
111
+ describe("executeAllWithResolver + fromApprovalMap", () => {
112
+ it("approval: all gated approved → tools execute, structured Values", async () => {
113
+ const approvals = new Map<string, ApprovalMapEntry>([
114
+ ["c2", { decision: "approve" }],
115
+ ["c3", { decision: "approve" }],
116
+ ])
117
+ const collected = await Effect.runPromise(
118
+ Stream.runCollect(
119
+ executeAllWithResolver(allTools, calls, fromApprovalMap(isSensitive, approvals)),
120
+ ),
121
+ )
122
+ const by = byCallId(resultsFrom(collected))
123
+ expect(by.get("c1")).toMatchObject({ _tag: "Value", value: { count: 3 } })
124
+ expect(by.get("c2")).toMatchObject({
125
+ _tag: "Value",
126
+ value: { status: "sent", delivered: 2 },
127
+ })
128
+ expect(by.get("c3")).toMatchObject({
129
+ _tag: "Value",
130
+ value: { status: "dropped", name: "prod" },
131
+ })
132
+
133
+ // No ApprovalRequested events from the pure HTTP flow.
134
+ expect(collected.filter(isApprovalRequested)).toHaveLength(0)
135
+ })
136
+
137
+ it("denial: gated denied → Failure(denied), no execution", async () => {
138
+ const approvals = new Map<string, ApprovalMapEntry>([
139
+ ["c2", { decision: "deny", reason: "spam concern" }],
140
+ ["c3", { decision: "deny", reason: "prod is sacred" }],
141
+ ])
142
+ const collected = await Effect.runPromise(
143
+ Stream.runCollect(
144
+ executeAllWithResolver(allTools, calls, fromApprovalMap(isSensitive, approvals)),
145
+ ),
146
+ )
147
+
148
+ // bulk_email never ran.
149
+ expect(
150
+ collected.filter(isIntermediate).filter((e) => e.tool === "bulk_email"),
151
+ ).toHaveLength(0)
152
+
153
+ const by = byCallId(resultsFrom(collected))
154
+ expect(by.get("c2")).toMatchObject({
155
+ _tag: "Failure",
156
+ kind: "denied",
157
+ reason: "spam concern",
158
+ })
159
+ expect(by.get("c3")).toMatchObject({
160
+ _tag: "Failure",
161
+ kind: "denied",
162
+ reason: "prod is sacred",
163
+ })
164
+ })
165
+
166
+ it("cancellation: missing verdicts → Failure(cancelled)", async () => {
167
+ const collected = await Effect.runPromise(
168
+ Stream.runCollect(
169
+ executeAllWithResolver(
170
+ allTools,
171
+ calls,
172
+ fromApprovalMap(isSensitive, new Map()),
173
+ ),
174
+ ),
175
+ )
176
+ const by = byCallId(resultsFrom(collected))
177
+ expect(by.get("c1")).toMatchObject({ _tag: "Value", value: { count: 3 } })
178
+ expect(by.get("c2")).toMatchObject({ _tag: "Failure", kind: "cancelled" })
179
+ expect(by.get("c3")).toMatchObject({ _tag: "Failure", kind: "cancelled" })
180
+ })
181
+
182
+ it("mixed: approve + deny + omit → all three kinds", async () => {
183
+ const approvals = new Map<string, ApprovalMapEntry>([
184
+ ["c2", { decision: "approve" }],
185
+ // c3 omitted → cancelled
186
+ ])
187
+ const collected = await Effect.runPromise(
188
+ Stream.runCollect(
189
+ executeAllWithResolver(allTools, calls, fromApprovalMap(isSensitive, approvals)),
190
+ ),
191
+ )
192
+ const by = byCallId(resultsFrom(collected))
193
+ expect(by.get("c1")).toMatchObject({ _tag: "Value", value: { count: 3 } })
194
+ expect(by.get("c2")).toMatchObject({ _tag: "Value", value: { status: "sent" } })
195
+ expect(by.get("c3")).toMatchObject({ _tag: "Failure", kind: "cancelled" })
196
+ })
197
+ })
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Graceful degradation: hallucinated tool name doesn't kill the turn.
201
+ // ---------------------------------------------------------------------------
202
+
203
+ describe("executeAllWithResolver: graceful degradation", () => {
204
+ it("unknown tool name → Failure(unknown_tool); other calls still execute", async () => {
205
+ const callsWithBogus = [
206
+ fc("c1", "web_search", { query: "x" }),
207
+ fc("c2", "does_not_exist", {}),
208
+ fc("c3", "delete_database", { name: "prod" }),
209
+ ]
210
+ const collected = await Effect.runPromise(
211
+ Stream.runCollect(
212
+ executeAllWithResolver(
213
+ allTools,
214
+ callsWithBogus,
215
+ fromApprovalMap(isSensitive, new Map([["c3", { decision: "approve" }]])),
216
+ ),
217
+ ),
218
+ )
219
+ const by = byCallId(resultsFrom(collected))
220
+ expect(by.get("c1")).toMatchObject({ _tag: "Value" })
221
+ expect(by.get("c2")).toMatchObject({ _tag: "Failure", kind: "unknown_tool" })
222
+ expect(by.get("c3")).toMatchObject({ _tag: "Value", value: { status: "dropped" } })
223
+ })
224
+ })
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // fromVerdictQueue: WebSocket-style scenarios
228
+ // ---------------------------------------------------------------------------
229
+
230
+ describe("executeAllWithResolver + fromVerdictQueue", () => {
231
+ it("queue-driven: approve + deny resolve correctly with ApprovalRequested events", async () => {
232
+ const collected = await Effect.runPromise(
233
+ Effect.gen(function* () {
234
+ const verdicts = yield* Queue.unbounded<{
235
+ readonly call_id: string
236
+ readonly decision: "approve" | "deny"
237
+ readonly reason?: string
238
+ }>()
239
+ yield* Queue.offer(verdicts, { call_id: "c2", decision: "approve" })
240
+ yield* Queue.offer(verdicts, {
241
+ call_id: "c3",
242
+ decision: "deny",
243
+ reason: "too risky",
244
+ })
245
+
246
+ // Stream.unwrap supplies the Scope for fromVerdictQueue's router.
247
+ const events = Stream.unwrap(
248
+ Effect.gen(function* () {
249
+ const { resolve, announce } = yield* fromVerdictQueue(
250
+ isSensitive,
251
+ verdicts,
252
+ )(calls)
253
+ return Stream.merge(announce, executeAllWithResolver(allTools, calls, resolve))
254
+ }),
255
+ )
256
+ return yield* Stream.runCollect(events)
257
+ }),
258
+ )
259
+
260
+ expect(collected.filter(isApprovalRequested)).toHaveLength(2)
261
+
262
+ const by = byCallId(resultsFrom(collected))
263
+ expect(by.get("c2")).toMatchObject({ _tag: "Value", value: { status: "sent" } })
264
+ expect(by.get("c3")).toMatchObject({
265
+ _tag: "Failure",
266
+ kind: "denied",
267
+ reason: "too risky",
268
+ })
269
+ })
270
+ })
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Combinators
274
+ // ---------------------------------------------------------------------------
275
+
276
+ describe("withPermissions / withFallback", () => {
277
+ it("withPermissions short-circuits with permission_denied when canApprove returns false", async () => {
278
+ const canApprove = (call: Items.FunctionCall) =>
279
+ Effect.succeed(call.name !== "delete_database")
280
+ const inner = fromApprovalMap(
281
+ isSensitive,
282
+ new Map<string, ApprovalMapEntry>([
283
+ ["c2", { decision: "approve" }],
284
+ ["c3", { decision: "approve" }],
285
+ ]),
286
+ )
287
+ const collected = await Effect.runPromise(
288
+ Stream.runCollect(
289
+ executeAllWithResolver(allTools, calls, withPermissions(inner, canApprove)),
290
+ ),
291
+ )
292
+ const by = byCallId(resultsFrom(collected))
293
+ // c2 allowed → executed; c3 forbidden → permission_denied (no exec)
294
+ expect(by.get("c2")).toMatchObject({ _tag: "Value", value: { status: "sent" } })
295
+ expect(by.get("c3")).toMatchObject({
296
+ _tag: "Failure",
297
+ kind: "permission_denied",
298
+ })
299
+ })
300
+
301
+ it("withFallback recovers a Reject by running an alternate decision", async () => {
302
+ const inner = fromApprovalMap(
303
+ isSensitive,
304
+ new Map<string, ApprovalMapEntry>([
305
+ ["c2", { decision: "deny", reason: "no" }],
306
+ ["c3", { decision: "approve" }],
307
+ ]),
308
+ )
309
+ // Recover only `denied` rejections; turn them into Execute (re-run anyway).
310
+ const recoverable = (r: ToolResult) => isFailure(r) && r.kind === "denied"
311
+ const fallbackResolver = withFallback(inner, recoverable, () =>
312
+ Effect.succeed({ _tag: "Execute" } as const),
313
+ )
314
+ const collected = await Effect.runPromise(
315
+ Stream.runCollect(executeAllWithResolver(allTools, calls, fallbackResolver)),
316
+ )
317
+ const by = byCallId(resultsFrom(collected))
318
+ // c2 was denied but fallback re-ran the tool.
319
+ expect(by.get("c2")).toMatchObject({ _tag: "Value", value: { status: "sent" } })
320
+ expect(by.get("c3")).toMatchObject({ _tag: "Value", value: { status: "dropped" } })
321
+ })
322
+ })
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // History reconciliation
326
+ // ---------------------------------------------------------------------------
327
+
328
+ describe("findUnansweredCalls / cancelAllPending / isReconciled", () => {
329
+ const orphan = fc("c99", "delete_database", { name: "prod" })
330
+ const answered = fc("c98", "web_search", { query: "x" })
331
+ const answeredOutput = Items.functionCallOutput("c98", JSON.stringify({ count: 0 }))
332
+
333
+ it("findUnansweredCalls returns only orphans", () => {
334
+ const history = [Items.userText("hi"), answered, orphan, answeredOutput]
335
+ const unanswered = findUnansweredCalls(history)
336
+ expect(unanswered).toHaveLength(1)
337
+ expect(unanswered[0]!.call_id).toBe("c99")
338
+ })
339
+
340
+ it("isReconciled is false when orphans exist, true otherwise", () => {
341
+ const stale = [Items.userText("hi"), orphan]
342
+ expect(isReconciled(stale)).toBe(false)
343
+ const reconciled = [...stale, ...cancelAllPending(stale).map(toFunctionCallOutput)]
344
+ expect(isReconciled(reconciled)).toBe(true)
345
+ })
346
+
347
+ it("cancelAllPending synthesizes one Failure(cancelled) per orphan", () => {
348
+ const history = [Items.userText("hi"), answered, orphan, answeredOutput]
349
+ const closures = cancelAllPending(history, "user moved on")
350
+ expect(closures).toHaveLength(1)
351
+ const c = closures[0]!
352
+ expect(isFailure(c)).toBe(true)
353
+ expect(c).toMatchObject({
354
+ _tag: "Failure",
355
+ call_id: "c99",
356
+ kind: "cancelled",
357
+ reason: "user moved on",
358
+ })
359
+ })
360
+
361
+ it("follow-up: map closures to FunctionCallOutput before appending new user message", () => {
362
+ const stale = [Items.userText("first request"), orphan]
363
+ const closures = cancelAllPending(stale, "user redirected")
364
+ const reconciled = [
365
+ ...stale,
366
+ ...closures.map(toFunctionCallOutput),
367
+ Items.userText("never mind"),
368
+ ]
369
+ expect(findUnansweredCalls(reconciled)).toHaveLength(0)
370
+ })
371
+ })
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // Wire conversion
375
+ // ---------------------------------------------------------------------------
376
+
377
+ describe("toFunctionCallOutput", () => {
378
+ it("round-trips a Value result", () => {
379
+ const r: ToolResult = {
380
+ _tag: "Value",
381
+ call_id: "c1",
382
+ tool: "web_search",
383
+ value: { count: 3 },
384
+ }
385
+ const out = toFunctionCallOutput(r)
386
+ expect(out.call_id).toBe("c1")
387
+ expect(JSON.parse(out.output)).toEqual({ count: 3 })
388
+ })
389
+
390
+ it("round-trips a Failure result with reason", () => {
391
+ const r: ToolResult = {
392
+ _tag: "Failure",
393
+ call_id: "c2",
394
+ tool: "bulk_email",
395
+ kind: "denied",
396
+ reason: "spam concern",
397
+ }
398
+ const out = toFunctionCallOutput(r)
399
+ expect(JSON.parse(out.output)).toEqual({ kind: "denied", reason: "spam concern" })
400
+ })
401
+
402
+ it("round-trips a Failure result without reason (omits the field)", () => {
403
+ const r: ToolResult = {
404
+ _tag: "Failure",
405
+ call_id: "c3",
406
+ tool: "delete_database",
407
+ kind: "cancelled",
408
+ }
409
+ const out = toFunctionCallOutput(r)
410
+ expect(JSON.parse(out.output)).toEqual({ kind: "cancelled" })
411
+ })
412
+ })
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // executeAll (no-resolver shortcut)
416
+ // ---------------------------------------------------------------------------
417
+
418
+ describe("executeAll", () => {
419
+ it("equivalent to executeAllWithResolver with allow-all resolver", async () => {
420
+ const collected = await Effect.runPromise(
421
+ Stream.runCollect(executeAll(allTools, calls)),
422
+ )
423
+ expect(collected.filter(isOutput)).toHaveLength(3)
424
+ expect(collected.filter(isOutput).every((e) => isValue(e.result))).toBe(true)
425
+ })
426
+ })
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Ready-made `Resolver`s for the two transport flavors plus combinators
3
+ * for layering policy on top.
4
+ *
5
+ * - `fromVerdictQueue` : long-lived channel (WebSocket / SSE).
6
+ * - `fromApprovalMap` : request-shaped (HTTP chat).
7
+ * - `withPermissions` : authz wrapper.
8
+ * - `withFallback` : recovery wrapper.
9
+ *
10
+ * None of these know about the executor's stream shape; they just produce
11
+ * `Effect<ToolDecision>`s a `Resolver` can return.
12
+ */
13
+ import { Deferred, Effect, Queue, Scope, Stream } from "effect"
14
+ import type { FunctionCall } from "../domain/Items.js"
15
+ import {
16
+ type ToolDecision,
17
+ type ToolResult,
18
+ cancelled,
19
+ denied,
20
+ execute,
21
+ reject,
22
+ rejected,
23
+ } from "./Outcome.js"
24
+ import type { Resolver } from "./Toolkit.js"
25
+ import type { ToolEvent } from "./ToolEvent.js"
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Verdict queue (WebSocket-style transport).
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export interface Verdict {
32
+ readonly call_id: string
33
+ readonly decision: "approve" | "deny"
34
+ readonly reason?: string
35
+ }
36
+
37
+ /**
38
+ * Queue-backed resolver. The router fiber drains verdicts and resolves
39
+ * pre-registered Deferreds keyed by `call_id`. Returns the resolver and
40
+ * a stream of `ApprovalRequested` events for the gated calls; the recipe
41
+ * merges the announce stream into its consumer view.
42
+ */
43
+ export const fromVerdictQueue =
44
+ (
45
+ predicate: (call: FunctionCall) => boolean,
46
+ verdicts: Queue.Dequeue<Verdict>,
47
+ ) =>
48
+ (
49
+ calls: ReadonlyArray<FunctionCall>,
50
+ ): Effect.Effect<
51
+ {
52
+ readonly resolve: Resolver
53
+ readonly announce: Stream.Stream<ToolEvent>
54
+ },
55
+ never,
56
+ Scope.Scope
57
+ > =>
58
+ Effect.gen(function* () {
59
+ const gated = calls.filter(predicate)
60
+
61
+ const entries = yield* Effect.forEach(gated, (call) =>
62
+ Deferred.make<Verdict>().pipe(Effect.map((d) => [call.call_id, d] as const)),
63
+ )
64
+ const deferreds: ReadonlyMap<string, Deferred.Deferred<Verdict>> = new Map(entries)
65
+
66
+ // Router is forked into the surrounding Scope so it lives as long
67
+ // as the consumer is pulling events. Recipes typically supply the
68
+ // scope by wrapping the events construction in `Stream.unwrap`.
69
+ yield* Effect.forkScoped(
70
+ Effect.forever(
71
+ Effect.gen(function* () {
72
+ const v = yield* Queue.take(verdicts)
73
+ const d = deferreds.get(v.call_id)
74
+ if (d !== undefined) yield* Deferred.succeed(d, v)
75
+ }),
76
+ ),
77
+ )
78
+
79
+ const resolve: Resolver = (call) => {
80
+ if (!predicate(call)) return Effect.succeed(execute)
81
+ const d = deferreds.get(call.call_id)!
82
+ return Deferred.await(d).pipe(
83
+ Effect.map((v) =>
84
+ v.decision === "approve" ? execute : reject(denied(call, v.reason)),
85
+ ),
86
+ )
87
+ }
88
+
89
+ const announce = Stream.fromIterable<ToolEvent>(
90
+ gated.map((call) => ({
91
+ _tag: "ApprovalRequested",
92
+ call_id: call.call_id,
93
+ tool: call.name,
94
+ arguments: call.arguments,
95
+ })),
96
+ )
97
+
98
+ return { resolve, announce }
99
+ })
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Approval map (HTTP-style transport). Verdicts arrive synchronously
103
+ // bundled in the request payload. Missing entries → cancelled.
104
+ // ---------------------------------------------------------------------------
105
+
106
+ export type ApprovalMapEntry =
107
+ | { readonly decision: "approve" }
108
+ | { readonly decision: "deny"; readonly reason?: string }
109
+
110
+ export const fromApprovalMap =
111
+ (
112
+ predicate: (call: FunctionCall) => boolean,
113
+ approvals: ReadonlyMap<string, ApprovalMapEntry>,
114
+ ): Resolver =>
115
+ (call) => {
116
+ if (!predicate(call)) return Effect.succeed(execute)
117
+ const v = approvals.get(call.call_id)
118
+ if (v === undefined) return Effect.succeed(reject(cancelled(call)))
119
+ return Effect.succeed(
120
+ v.decision === "approve" ? execute : reject(denied(call, v.reason)),
121
+ )
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Combinators - compose policy onto an inner resolver.
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Authz gate. `canApprove` runs BEFORE the inner resolver; failures
130
+ * short-circuit to a `permission_denied` rejection. Override `onForbidden`
131
+ * if your audit format wants a different kind or reason.
132
+ */
133
+ export const withPermissions =
134
+ (
135
+ inner: Resolver,
136
+ canApprove: (call: FunctionCall) => Effect.Effect<boolean>,
137
+ onForbidden: (call: FunctionCall) => ToolResult = (call) =>
138
+ rejected(call, "permission_denied", "missing permissions"),
139
+ ): Resolver =>
140
+ (call) =>
141
+ canApprove(call).pipe(
142
+ Effect.flatMap((allowed) =>
143
+ allowed ? inner(call) : Effect.succeed(reject(onForbidden(call))),
144
+ ),
145
+ )
146
+
147
+ /**
148
+ * Fallback gate. If `inner` returns a Reject whose result matches the
149
+ * `recoverable` predicate, run `fallback(call)` instead and use that
150
+ * decision. Otherwise pass the original Reject through.
151
+ */
152
+ export const withFallback =
153
+ (
154
+ inner: Resolver,
155
+ recoverable: (result: ToolResult) => boolean,
156
+ fallback: (call: FunctionCall) => Effect.Effect<ToolDecision>,
157
+ ): Resolver =>
158
+ (call) =>
159
+ inner(call).pipe(
160
+ Effect.flatMap((decision) =>
161
+ decision._tag === "Reject" && recoverable(decision.result)
162
+ ? fallback(call)
163
+ : Effect.succeed(decision),
164
+ ),
165
+ )
166
+