@effect-uai/core 0.1.0 → 0.3.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 (149) hide show
  1. package/README.md +2 -2
  2. package/dist/{AiError-CqmYjXyx.d.mts → AiError-CBuPHVKA.d.mts} +1 -1
  3. package/dist/{AiError-CqmYjXyx.d.mts.map → AiError-CBuPHVKA.d.mts.map} +1 -1
  4. package/dist/Image-BZmKfIdq.d.mts +61 -0
  5. package/dist/Image-BZmKfIdq.d.mts.map +1 -0
  6. package/dist/{Items-D1C2686t.d.mts → Items-CB8Bo3FI.d.mts} +132 -80
  7. package/dist/Items-CB8Bo3FI.d.mts.map +1 -0
  8. package/dist/Media-D_CpcM1Z.d.mts +57 -0
  9. package/dist/Media-D_CpcM1Z.d.mts.map +1 -0
  10. package/dist/{StructuredFormat-B5ueioNr.d.mts → StructuredFormat-BWq5Hd1O.d.mts} +5 -5
  11. package/dist/StructuredFormat-BWq5Hd1O.d.mts.map +1 -0
  12. package/dist/{Tool-5wxOCuOh.d.mts → Tool-DjVufH7i.d.mts} +13 -13
  13. package/dist/Tool-DjVufH7i.d.mts.map +1 -0
  14. package/dist/{Turn-rlTfuHaQ.d.mts → Turn-OPaILVIB.d.mts} +12 -29
  15. package/dist/Turn-OPaILVIB.d.mts.map +1 -0
  16. package/dist/{chunk-CfYAbeIz.mjs → chunk-uyGKjUfl.mjs} +2 -1
  17. package/dist/dist-DV5ISja1.mjs +13782 -0
  18. package/dist/dist-DV5ISja1.mjs.map +1 -0
  19. package/dist/domain/AiError.d.mts +1 -1
  20. package/dist/domain/AiError.mjs +1 -1
  21. package/dist/domain/Image.d.mts +2 -0
  22. package/dist/domain/Image.mjs +58 -0
  23. package/dist/domain/Image.mjs.map +1 -0
  24. package/dist/domain/Items.d.mts +2 -2
  25. package/dist/domain/Items.mjs +19 -42
  26. package/dist/domain/Items.mjs.map +1 -1
  27. package/dist/domain/Media.d.mts +2 -0
  28. package/dist/domain/Media.mjs +14 -0
  29. package/dist/domain/Media.mjs.map +1 -0
  30. package/dist/domain/Turn.d.mts +2 -2
  31. package/dist/domain/Turn.mjs +12 -8
  32. package/dist/domain/Turn.mjs.map +1 -1
  33. package/dist/embedding-model/Embedding.d.mts +107 -0
  34. package/dist/embedding-model/Embedding.d.mts.map +1 -0
  35. package/dist/embedding-model/Embedding.mjs +18 -0
  36. package/dist/embedding-model/Embedding.mjs.map +1 -0
  37. package/dist/embedding-model/EmbeddingModel.d.mts +97 -0
  38. package/dist/embedding-model/EmbeddingModel.d.mts.map +1 -0
  39. package/dist/embedding-model/EmbeddingModel.mjs +17 -0
  40. package/dist/embedding-model/EmbeddingModel.mjs.map +1 -0
  41. package/dist/index.d.mts +16 -8
  42. package/dist/index.mjs +10 -2
  43. package/dist/language-model/LanguageModel.d.mts +12 -20
  44. package/dist/language-model/LanguageModel.d.mts.map +1 -1
  45. package/dist/language-model/LanguageModel.mjs +3 -20
  46. package/dist/language-model/LanguageModel.mjs.map +1 -1
  47. package/dist/loop/Loop.d.mts +111 -2
  48. package/dist/loop/Loop.d.mts.map +1 -0
  49. package/dist/loop/Loop.mjs +39 -6
  50. package/dist/loop/Loop.mjs.map +1 -1
  51. package/dist/loop/Loop.test.d.mts +1 -0
  52. package/dist/loop/Loop.test.mjs +411 -0
  53. package/dist/loop/Loop.test.mjs.map +1 -0
  54. package/dist/magic-string.es-BgIV5Mu3.mjs +1013 -0
  55. package/dist/magic-string.es-BgIV5Mu3.mjs.map +1 -0
  56. package/dist/math/Vector.d.mts +47 -0
  57. package/dist/math/Vector.d.mts.map +1 -0
  58. package/dist/math/Vector.mjs +117 -0
  59. package/dist/math/Vector.mjs.map +1 -0
  60. package/dist/observability/Metrics.d.mts +2 -2
  61. package/dist/observability/Metrics.d.mts.map +1 -1
  62. package/dist/observability/Metrics.mjs +1 -1
  63. package/dist/observability/Metrics.mjs.map +1 -1
  64. package/dist/streaming/JSONL.mjs +1 -1
  65. package/dist/streaming/JSONL.test.d.mts +1 -0
  66. package/dist/streaming/JSONL.test.mjs +70 -0
  67. package/dist/streaming/JSONL.test.mjs.map +1 -0
  68. package/dist/streaming/Lines.mjs +1 -1
  69. package/dist/streaming/SSE.d.mts +2 -2
  70. package/dist/streaming/SSE.d.mts.map +1 -1
  71. package/dist/streaming/SSE.mjs +1 -1
  72. package/dist/streaming/SSE.mjs.map +1 -1
  73. package/dist/streaming/SSE.test.d.mts +1 -0
  74. package/dist/streaming/SSE.test.mjs +72 -0
  75. package/dist/streaming/SSE.test.mjs.map +1 -0
  76. package/dist/structured-format/StructuredFormat.d.mts +1 -1
  77. package/dist/structured-format/StructuredFormat.mjs +1 -1
  78. package/dist/structured-format/StructuredFormat.mjs.map +1 -1
  79. package/dist/testing/MockProvider.d.mts +6 -6
  80. package/dist/testing/MockProvider.d.mts.map +1 -1
  81. package/dist/testing/MockProvider.mjs.map +1 -1
  82. package/dist/tool/HistoryCheck.d.mts +6 -3
  83. package/dist/tool/HistoryCheck.d.mts.map +1 -1
  84. package/dist/tool/HistoryCheck.mjs +7 -1
  85. package/dist/tool/HistoryCheck.mjs.map +1 -1
  86. package/dist/tool/Outcome.d.mts +138 -2
  87. package/dist/tool/Outcome.d.mts.map +1 -0
  88. package/dist/tool/Outcome.mjs +34 -18
  89. package/dist/tool/Outcome.mjs.map +1 -1
  90. package/dist/tool/Resolvers.d.mts +30 -25
  91. package/dist/tool/Resolvers.d.mts.map +1 -1
  92. package/dist/tool/Resolvers.mjs +54 -44
  93. package/dist/tool/Resolvers.mjs.map +1 -1
  94. package/dist/tool/Resolvers.test.d.mts +1 -0
  95. package/dist/tool/Resolvers.test.mjs +317 -0
  96. package/dist/tool/Resolvers.test.mjs.map +1 -0
  97. package/dist/tool/Tool.d.mts +1 -1
  98. package/dist/tool/Tool.mjs +1 -1
  99. package/dist/tool/Tool.mjs.map +1 -1
  100. package/dist/tool/ToolEvent.d.mts +151 -2
  101. package/dist/tool/ToolEvent.d.mts.map +1 -0
  102. package/dist/tool/ToolEvent.mjs +30 -4
  103. package/dist/tool/ToolEvent.mjs.map +1 -1
  104. package/dist/tool/Toolkit.d.mts +24 -15
  105. package/dist/tool/Toolkit.d.mts.map +1 -1
  106. package/dist/tool/Toolkit.mjs +14 -13
  107. package/dist/tool/Toolkit.mjs.map +1 -1
  108. package/dist/tool/Toolkit.test.d.mts +1 -0
  109. package/dist/tool/Toolkit.test.mjs +113 -0
  110. package/dist/tool/Toolkit.test.mjs.map +1 -0
  111. package/package.json +29 -13
  112. package/src/domain/Image.ts +75 -0
  113. package/src/domain/Items.ts +18 -47
  114. package/src/domain/Media.ts +61 -0
  115. package/src/domain/Turn.ts +7 -17
  116. package/src/embedding-model/Embedding.ts +117 -0
  117. package/src/embedding-model/EmbeddingModel.ts +107 -0
  118. package/src/index.ts +9 -1
  119. package/src/language-model/LanguageModel.ts +2 -22
  120. package/src/loop/Loop.test.ts +114 -2
  121. package/src/loop/Loop.ts +69 -5
  122. package/src/math/Vector.ts +138 -0
  123. package/src/observability/Metrics.ts +1 -1
  124. package/src/streaming/SSE.ts +1 -1
  125. package/src/structured-format/StructuredFormat.ts +2 -2
  126. package/src/testing/MockProvider.ts +2 -2
  127. package/src/tool/HistoryCheck.ts +2 -5
  128. package/src/tool/Outcome.ts +39 -53
  129. package/src/tool/Resolvers.test.ts +46 -117
  130. package/src/tool/Resolvers.ts +74 -102
  131. package/src/tool/Tool.ts +9 -9
  132. package/src/tool/ToolEvent.ts +30 -26
  133. package/src/tool/Toolkit.test.ts +97 -2
  134. package/src/tool/Toolkit.ts +65 -67
  135. package/dist/Items-D1C2686t.d.mts.map +0 -1
  136. package/dist/Loop-CzSJo1h8.d.mts +0 -87
  137. package/dist/Loop-CzSJo1h8.d.mts.map +0 -1
  138. package/dist/Outcome-C2JYknCu.d.mts +0 -40
  139. package/dist/Outcome-C2JYknCu.d.mts.map +0 -1
  140. package/dist/StructuredFormat-B5ueioNr.d.mts.map +0 -1
  141. package/dist/Tool-5wxOCuOh.d.mts.map +0 -1
  142. package/dist/ToolEvent-B2N10hr3.d.mts +0 -29
  143. package/dist/ToolEvent-B2N10hr3.d.mts.map +0 -1
  144. package/dist/Turn-rlTfuHaQ.d.mts.map +0 -1
  145. package/dist/match/Match.d.mts +0 -16
  146. package/dist/match/Match.d.mts.map +0 -1
  147. package/dist/match/Match.mjs +0 -15
  148. package/dist/match/Match.mjs.map +0 -1
  149. package/src/match/Match.ts +0 -9
@@ -1,7 +1,7 @@
1
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:
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 `executeAll`, with the four wire-shaped scenarios:
5
5
  *
6
6
  * 1. Approval : gated calls approved → tools execute, structured Values
7
7
  * 2. Denial : gated calls denied → Failure(denied) results
@@ -14,27 +14,16 @@ import { Effect, Queue, Schema, Stream } from "effect"
14
14
  import { describe, expect, it } from "vitest"
15
15
  import * as Items from "../domain/Items.js"
16
16
  import { findUnansweredCalls, cancelAllPending, isReconciled } from "./HistoryCheck.js"
17
- import {
18
- type ToolResult,
19
- isFailure,
20
- isValue,
21
- toFunctionCallOutput,
22
- } from "./Outcome.js"
17
+ import { type ToolResult, isFailure, isValue, toFunctionCallOutput } from "./Outcome.js"
23
18
  import {
24
19
  type ApprovalMapEntry,
20
+ type ToolCallDecision,
25
21
  fromApprovalMap,
26
22
  fromVerdictQueue,
27
- withFallback,
28
- withPermissions,
29
23
  } from "./Resolvers.js"
30
24
  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"
25
+ import { executeAll, outputEvent, outputEvents } from "./Toolkit.js"
26
+ import { type ToolEvent, isApprovalRequested, isIntermediate, isOutput } from "./ToolEvent.js"
38
27
 
39
28
  // ---------------------------------------------------------------------------
40
29
  // Three demo tools covering the matrix:
@@ -97,28 +86,32 @@ const calls = [
97
86
  fc("c3", "delete_database", { name: "prod" }),
98
87
  ]
99
88
 
100
- const resultsFrom = (
101
- collected: ReadonlyArray<ToolEvent>,
102
- ): ReadonlyArray<ToolResult> => collected.filter(isOutput).map((e) => e.result)
89
+ const resultsFrom = (collected: ReadonlyArray<ToolEvent>): ReadonlyArray<ToolResult> =>
90
+ collected.filter(isOutput).map((e) => e.result)
91
+
92
+ const byCallId = (results: ReadonlyArray<ToolResult>) => new Map(results.map((r) => [r.call_id, r]))
103
93
 
104
- const byCallId = (results: ReadonlyArray<ToolResult>) =>
105
- new Map(results.map((r) => [r.call_id, r]))
94
+ const eventsFromApprovalMap = (approvals: ReadonlyMap<string, ApprovalMapEntry>) => {
95
+ const plan = fromApprovalMap(isSensitive, approvals)(calls)
96
+ return Stream.merge(executeAll(allTools, plan.approved), outputEvents(plan.rejected))
97
+ }
98
+
99
+ const eventsFromDecision = (decision: ToolCallDecision): Stream.Stream<ToolEvent> =>
100
+ decision._tag === "Approved"
101
+ ? executeAll(allTools, [decision.call])
102
+ : Stream.succeed(outputEvent(decision.result))
106
103
 
107
104
  // ---------------------------------------------------------------------------
108
105
  // fromApprovalMap: HTTP-style scenarios
109
106
  // ---------------------------------------------------------------------------
110
107
 
111
- describe("executeAllWithResolver + fromApprovalMap", () => {
108
+ describe("fromApprovalMap + executeAll", () => {
112
109
  it("approval: all gated approved → tools execute, structured Values", async () => {
113
110
  const approvals = new Map<string, ApprovalMapEntry>([
114
111
  ["c2", { decision: "approve" }],
115
112
  ["c3", { decision: "approve" }],
116
113
  ])
117
- const collected = await Effect.runPromise(
118
- Stream.runCollect(
119
- executeAllWithResolver(allTools, calls, fromApprovalMap(isSensitive, approvals)),
120
- ),
121
- )
114
+ const collected = await Effect.runPromise(Stream.runCollect(eventsFromApprovalMap(approvals)))
122
115
  const by = byCallId(resultsFrom(collected))
123
116
  expect(by.get("c1")).toMatchObject({ _tag: "Value", value: { count: 3 } })
124
117
  expect(by.get("c2")).toMatchObject({
@@ -139,16 +132,10 @@ describe("executeAllWithResolver + fromApprovalMap", () => {
139
132
  ["c2", { decision: "deny", reason: "spam concern" }],
140
133
  ["c3", { decision: "deny", reason: "prod is sacred" }],
141
134
  ])
142
- const collected = await Effect.runPromise(
143
- Stream.runCollect(
144
- executeAllWithResolver(allTools, calls, fromApprovalMap(isSensitive, approvals)),
145
- ),
146
- )
135
+ const collected = await Effect.runPromise(Stream.runCollect(eventsFromApprovalMap(approvals)))
147
136
 
148
137
  // bulk_email never ran.
149
- expect(
150
- collected.filter(isIntermediate).filter((e) => e.tool === "bulk_email"),
151
- ).toHaveLength(0)
138
+ expect(collected.filter(isIntermediate).filter((e) => e.tool === "bulk_email")).toHaveLength(0)
152
139
 
153
140
  const by = byCallId(resultsFrom(collected))
154
141
  expect(by.get("c2")).toMatchObject({
@@ -164,15 +151,7 @@ describe("executeAllWithResolver + fromApprovalMap", () => {
164
151
  })
165
152
 
166
153
  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
- )
154
+ const collected = await Effect.runPromise(Stream.runCollect(eventsFromApprovalMap(new Map())))
176
155
  const by = byCallId(resultsFrom(collected))
177
156
  expect(by.get("c1")).toMatchObject({ _tag: "Value", value: { count: 3 } })
178
157
  expect(by.get("c2")).toMatchObject({ _tag: "Failure", kind: "cancelled" })
@@ -184,11 +163,7 @@ describe("executeAllWithResolver + fromApprovalMap", () => {
184
163
  ["c2", { decision: "approve" }],
185
164
  // c3 omitted → cancelled
186
165
  ])
187
- const collected = await Effect.runPromise(
188
- Stream.runCollect(
189
- executeAllWithResolver(allTools, calls, fromApprovalMap(isSensitive, approvals)),
190
- ),
191
- )
166
+ const collected = await Effect.runPromise(Stream.runCollect(eventsFromApprovalMap(approvals)))
192
167
  const by = byCallId(resultsFrom(collected))
193
168
  expect(by.get("c1")).toMatchObject({ _tag: "Value", value: { count: 3 } })
194
169
  expect(by.get("c2")).toMatchObject({ _tag: "Value", value: { status: "sent" } })
@@ -200,7 +175,7 @@ describe("executeAllWithResolver + fromApprovalMap", () => {
200
175
  // Graceful degradation: hallucinated tool name doesn't kill the turn.
201
176
  // ---------------------------------------------------------------------------
202
177
 
203
- describe("executeAllWithResolver: graceful degradation", () => {
178
+ describe("executeAll: graceful degradation", () => {
204
179
  it("unknown tool name → Failure(unknown_tool); other calls still execute", async () => {
205
180
  const callsWithBogus = [
206
181
  fc("c1", "web_search", { query: "x" }),
@@ -209,11 +184,13 @@ describe("executeAllWithResolver: graceful degradation", () => {
209
184
  ]
210
185
  const collected = await Effect.runPromise(
211
186
  Stream.runCollect(
212
- executeAllWithResolver(
213
- allTools,
214
- callsWithBogus,
215
- fromApprovalMap(isSensitive, new Map([["c3", { decision: "approve" }]])),
216
- ),
187
+ (() => {
188
+ const plan = fromApprovalMap(
189
+ isSensitive,
190
+ new Map([["c3", { decision: "approve" }]]),
191
+ )(callsWithBogus)
192
+ return Stream.merge(executeAll(allTools, plan.approved), outputEvents(plan.rejected))
193
+ })(),
217
194
  ),
218
195
  )
219
196
  const by = byCallId(resultsFrom(collected))
@@ -227,7 +204,7 @@ describe("executeAllWithResolver: graceful degradation", () => {
227
204
  // fromVerdictQueue: WebSocket-style scenarios
228
205
  // ---------------------------------------------------------------------------
229
206
 
230
- describe("executeAllWithResolver + fromVerdictQueue", () => {
207
+ describe("fromVerdictQueue + executeAll", () => {
231
208
  it("queue-driven: approve + deny resolve correctly with ApprovalRequested events", async () => {
232
209
  const collected = await Effect.runPromise(
233
210
  Effect.gen(function* () {
@@ -246,11 +223,17 @@ describe("executeAllWithResolver + fromVerdictQueue", () => {
246
223
  // Stream.unwrap supplies the Scope for fromVerdictQueue's router.
247
224
  const events = Stream.unwrap(
248
225
  Effect.gen(function* () {
249
- const { resolve, announce } = yield* fromVerdictQueue(
226
+ const { approved, decisions, announce } = yield* fromVerdictQueue(
250
227
  isSensitive,
251
228
  verdicts,
252
229
  )(calls)
253
- return Stream.merge(announce, executeAllWithResolver(allTools, calls, resolve))
230
+ return Stream.merge(
231
+ announce,
232
+ Stream.merge(
233
+ executeAll(allTools, approved),
234
+ decisions.pipe(Stream.flatMap(eventsFromDecision)),
235
+ ),
236
+ )
254
237
  }),
255
238
  )
256
239
  return yield* Stream.runCollect(events)
@@ -269,58 +252,6 @@ describe("executeAllWithResolver + fromVerdictQueue", () => {
269
252
  })
270
253
  })
271
254
 
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
255
  // ---------------------------------------------------------------------------
325
256
  // History reconciliation
326
257
  // ---------------------------------------------------------------------------
@@ -412,14 +343,12 @@ describe("toFunctionCallOutput", () => {
412
343
  })
413
344
 
414
345
  // ---------------------------------------------------------------------------
415
- // executeAll (no-resolver shortcut)
346
+ // executeAll
416
347
  // ---------------------------------------------------------------------------
417
348
 
418
349
  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
- )
350
+ it("runs all calls passed to it", async () => {
351
+ const collected = await Effect.runPromise(Stream.runCollect(executeAll(allTools, calls)))
423
352
  expect(collected.filter(isOutput)).toHaveLength(3)
424
353
  expect(collected.filter(isOutput).every((e) => isValue(e.result))).toBe(true)
425
354
  })
@@ -1,55 +1,73 @@
1
1
  /**
2
- * Ready-made `Resolver`s for the two transport flavors plus combinators
3
- * for layering policy on top.
2
+ * Approval helpers for the two transport flavors.
4
3
  *
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.
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.executeAll`.
12
7
  */
13
8
  import { Deferred, Effect, Queue, Scope, Stream } from "effect"
14
9
  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"
10
+ import { type ToolResult, cancelled, denied } from "./Outcome.js"
25
11
  import type { ToolEvent } from "./ToolEvent.js"
26
12
 
13
+ export type ToolCallPlan = {
14
+ readonly approved: ReadonlyArray<FunctionCall>
15
+ readonly rejected: ReadonlyArray<ToolResult>
16
+ }
17
+
18
+ export type ToolCallDecision =
19
+ | { readonly _tag: "Approved"; readonly call: FunctionCall }
20
+ | { readonly _tag: "Rejected"; readonly result: ToolResult }
21
+
22
+ export const approve = (call: FunctionCall): ToolCallDecision => ({
23
+ _tag: "Approved",
24
+ call,
25
+ })
26
+
27
+ export const reject = (result: ToolResult): ToolCallDecision => ({
28
+ _tag: "Rejected",
29
+ result,
30
+ })
31
+
32
+ export const splitToolCallDecisions = (decisions: ReadonlyArray<ToolCallDecision>): ToolCallPlan =>
33
+ decisions.reduce<ToolCallPlan>(
34
+ (acc, decision) =>
35
+ decision._tag === "Approved"
36
+ ? { ...acc, approved: [...acc.approved, decision.call] }
37
+ : { ...acc, rejected: [...acc.rejected, decision.result] },
38
+ { approved: [], rejected: [] },
39
+ )
40
+
41
+ export const approvalRequested = (call: FunctionCall): ToolEvent => ({
42
+ _tag: "ApprovalRequested",
43
+ call_id: call.call_id,
44
+ tool: call.name,
45
+ arguments: call.arguments,
46
+ })
47
+
27
48
  // ---------------------------------------------------------------------------
28
49
  // Verdict queue (WebSocket-style transport).
29
50
  // ---------------------------------------------------------------------------
30
51
 
31
- export interface Verdict {
52
+ export type Verdict = {
32
53
  readonly call_id: string
33
54
  readonly decision: "approve" | "deny"
34
55
  readonly reason?: string
35
56
  }
36
57
 
37
58
  /**
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.
59
+ * Queue-backed approval planner. Safe calls are returned immediately in
60
+ * `approved`; gated calls emit `ApprovalRequested` events and later produce
61
+ * one `ToolCallDecision` when their matching verdict arrives.
42
62
  */
43
63
  export const fromVerdictQueue =
44
- (
45
- predicate: (call: FunctionCall) => boolean,
46
- verdicts: Queue.Dequeue<Verdict>,
47
- ) =>
64
+ (predicate: (call: FunctionCall) => boolean, verdicts: Queue.Dequeue<Verdict>) =>
48
65
  (
49
66
  calls: ReadonlyArray<FunctionCall>,
50
67
  ): Effect.Effect<
51
68
  {
52
- readonly resolve: Resolver
69
+ readonly approved: ReadonlyArray<FunctionCall>
70
+ readonly decisions: Stream.Stream<ToolCallDecision>
53
71
  readonly announce: Stream.Stream<ToolEvent>
54
72
  },
55
73
  never,
@@ -57,6 +75,7 @@ export const fromVerdictQueue =
57
75
  > =>
58
76
  Effect.gen(function* () {
59
77
  const gated = calls.filter(predicate)
78
+ const approved = calls.filter((call) => !predicate(call))
60
79
 
61
80
  const entries = yield* Effect.forEach(gated, (call) =>
62
81
  Deferred.make<Verdict>().pipe(Effect.map((d) => [call.call_id, d] as const)),
@@ -76,26 +95,25 @@ export const fromVerdictQueue =
76
95
  ),
77
96
  )
78
97
 
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
- })),
98
+ const decisions = Stream.fromIterable(gated).pipe(
99
+ Stream.flatMap(
100
+ (call) => {
101
+ const d = deferreds.get(call.call_id)!
102
+ return Stream.fromEffect(
103
+ Deferred.await(d).pipe(
104
+ Effect.map((v) =>
105
+ v.decision === "approve" ? approve(call) : reject(denied(call, v.reason)),
106
+ ),
107
+ ),
108
+ )
109
+ },
110
+ { concurrency: "unbounded" },
111
+ ),
96
112
  )
97
113
 
98
- return { resolve, announce }
114
+ const announce = Stream.fromIterable<ToolEvent>(gated.map(approvalRequested))
115
+
116
+ return { approved, decisions, announce }
99
117
  })
100
118
 
101
119
  // ---------------------------------------------------------------------------
@@ -108,59 +126,13 @@ export type ApprovalMapEntry =
108
126
  | { readonly decision: "deny"; readonly reason?: string }
109
127
 
110
128
  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
- ),
129
+ (predicate: (call: FunctionCall) => boolean, approvals: ReadonlyMap<string, ApprovalMapEntry>) =>
130
+ (calls: ReadonlyArray<FunctionCall>): ToolCallPlan =>
131
+ splitToolCallDecisions(
132
+ calls.map((call) => {
133
+ if (!predicate(call)) return approve(call)
134
+ const v = approvals.get(call.call_id)
135
+ if (v === undefined) return reject(cancelled(call))
136
+ return v.decision === "approve" ? approve(call) : reject(denied(call, v.reason))
137
+ }),
145
138
  )
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
-
package/src/tool/Tool.ts CHANGED
@@ -36,7 +36,7 @@ export const fromEffectSchema = <S extends Schema.Codec<any, any, never, any>>(
36
36
  Schema.toStandardJSONSchemaV1(Schema.toStandardSchemaV1(schema)) as unknown as S &
37
37
  ToolInputSchema<S["Type"]>
38
38
 
39
- export interface Tool<Name extends string, Input, Output, R = never> {
39
+ export type Tool<Name extends string, Input, Output, R = never> = {
40
40
  readonly name: Name
41
41
  readonly description: string
42
42
  readonly inputSchema: ToolInputSchema<Input>
@@ -55,7 +55,7 @@ export interface Tool<Name extends string, Input, Output, R = never> {
55
55
  * to its own wire field (OpenAI → `parameters`, Anthropic →
56
56
  * `input_schema`). Built from a `Tool` by `Toolkit.toDescriptors`.
57
57
  */
58
- export interface ToolDescriptor {
58
+ export type ToolDescriptor = {
59
59
  readonly name: string
60
60
  readonly description: string
61
61
  readonly inputSchema: Record<string, unknown>
@@ -75,7 +75,7 @@ export const make = <Name extends string, Input, Output, R = never>(
75
75
  // `Output`. Sub-agents, slow downloads with progress, recipe streamers.
76
76
  // ---------------------------------------------------------------------------
77
77
 
78
- export interface StreamingTool<Name extends string, Input, Event, Output, R = never> {
78
+ export type StreamingTool<Name extends string, Input, Event, Output, R = never> = {
79
79
  readonly _kind: "streaming"
80
80
  readonly name: Name
81
81
  readonly description: string
@@ -89,11 +89,11 @@ export const streaming = <Name extends string, Input, Event, Output, R = never>(
89
89
  spec: Omit<StreamingTool<Name, Input, Event, Output, R>, "_kind">,
90
90
  ): StreamingTool<Name, Input, Event, Output, R> => ({ _kind: "streaming", ...spec })
91
91
 
92
- export type AnyStreamingTool = StreamingTool<string, any, any, any, never>
93
- export type AnyPlainTool = Tool<string, any, any, never>
94
- export type AnyKindTool = AnyStreamingTool | AnyPlainTool
92
+ export type AnyStreamingTool<R = any> = StreamingTool<string, any, any, any, R>
93
+ export type AnyPlainTool<R = any> = Tool<string, any, any, R>
94
+ export type AnyKindTool<R = any> = AnyStreamingTool<R> | AnyPlainTool<R>
95
95
 
96
- export const isStreamingTool = (t: AnyKindTool): t is AnyStreamingTool =>
96
+ export const isStreamingTool = <R>(t: AnyKindTool<R>): t is AnyStreamingTool<R> =>
97
97
  "_kind" in t && t._kind === "streaming"
98
98
 
99
99
  /**
@@ -101,8 +101,8 @@ export const isStreamingTool = (t: AnyKindTool): t is AnyStreamingTool =>
101
101
  * descriptors. Mirrors `Toolkit.toDescriptors` but accepts the union type
102
102
  * so a single list can carry both kinds.
103
103
  */
104
- export const toDescriptors = (
105
- tools: ReadonlyArray<AnyKindTool>,
104
+ export const toDescriptors = <R>(
105
+ tools: ReadonlyArray<AnyKindTool<R>>,
106
106
  ): ReadonlyArray<ToolDescriptor> =>
107
107
  tools.map((tool) => {
108
108
  const inputSchema = tool.inputSchema["~standard"].jsonSchema.input({
@@ -1,37 +1,41 @@
1
1
  /**
2
- * The event type emitted by `Toolkit.executeAllWithResolver`.
2
+ * The event type emitted while handling tool calls.
3
3
  *
4
- * - ApprovalRequested : gated calls before resolver returns
4
+ * - ApprovalRequested : gated calls waiting for approval
5
5
  * - Intermediate : per-element passthrough from a streaming tool's run
6
6
  * - Output : terminal result (carries a structured ToolResult)
7
7
  *
8
- * Recipes thread `ToolEvent.Output.result` through `nextStateFrom` and apply
8
+ * Recipes thread `ToolEvent.Output.result` through `continueWith` and apply
9
9
  * `toFunctionCallOutput` when appending to history.
10
10
  */
11
+ import { Data } from "effect"
11
12
  import type { ToolResult } from "./Outcome.js"
12
13
 
13
- export type ToolEvent =
14
- | {
15
- readonly _tag: "ApprovalRequested"
16
- readonly call_id: string
17
- readonly tool: string
18
- readonly arguments: string
19
- }
20
- | {
21
- readonly _tag: "Intermediate"
22
- readonly call_id: string
23
- readonly tool: string
24
- readonly data: unknown
25
- }
26
- | { readonly _tag: "Output"; readonly result: ToolResult }
14
+ export type ToolEvent = Data.TaggedEnum<{
15
+ ApprovalRequested: {
16
+ readonly call_id: string
17
+ readonly tool: string
18
+ readonly arguments: string
19
+ }
20
+ Intermediate: {
21
+ readonly call_id: string
22
+ readonly tool: string
23
+ readonly data: unknown
24
+ }
25
+ Output: {
26
+ readonly result: ToolResult
27
+ }
28
+ }>
27
29
 
28
- export const isApprovalRequested = (
29
- e: ToolEvent,
30
- ): e is Extract<ToolEvent, { _tag: "ApprovalRequested" }> => e._tag === "ApprovalRequested"
31
-
32
- export const isIntermediate = (
33
- e: ToolEvent,
34
- ): e is Extract<ToolEvent, { _tag: "Intermediate" }> => e._tag === "Intermediate"
30
+ /**
31
+ * Namespace of constructors, type guards, and matchers for `ToolEvent`,
32
+ * provided by `Data.taggedEnum`. Use `ToolEvent.Output({ result })` to build
33
+ * an event, `ToolEvent.$is("Output")` for type narrowing,
34
+ * `ToolEvent.$match({ ApprovalRequested, Intermediate, Output })` for
35
+ * exhaustive pattern matching.
36
+ */
37
+ export const ToolEvent = Data.taggedEnum<ToolEvent>()
35
38
 
36
- export const isOutput = (e: ToolEvent): e is Extract<ToolEvent, { _tag: "Output" }> =>
37
- e._tag === "Output"
39
+ export const isApprovalRequested = ToolEvent.$is("ApprovalRequested")
40
+ export const isIntermediate = ToolEvent.$is("Intermediate")
41
+ export const isOutput = ToolEvent.$is("Output")