@effect-uai/core 0.8.0 → 0.10.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 (165) hide show
  1. package/dist/{AiError-CAX_48RU.d.mts → AiError-Bf1pq4BB.d.mts} +9 -4
  2. package/dist/{AiError-CAX_48RU.d.mts.map → AiError-Bf1pq4BB.d.mts.map} +1 -1
  3. package/dist/{Audio-DPlTsNdz.d.mts → Audio-CR7CSljJ.d.mts} +1 -1
  4. package/dist/{Audio-DPlTsNdz.d.mts.map → Audio-CR7CSljJ.d.mts.map} +1 -1
  5. package/dist/{Image-HNmMpMTh.d.mts → Image-BZmKfIdq.d.mts} +1 -1
  6. package/dist/{Image-HNmMpMTh.d.mts.map → Image-BZmKfIdq.d.mts.map} +1 -1
  7. package/dist/{Items-7ljaVxH_.d.mts → Items-BnIduEV2.d.mts} +7 -7
  8. package/dist/{Items-7ljaVxH_.d.mts.map → Items-BnIduEV2.d.mts.map} +1 -1
  9. package/dist/Loop-D8nWpowc.d.mts +173 -0
  10. package/dist/Loop-D8nWpowc.d.mts.map +1 -0
  11. package/dist/{SandboxError-BvKypkJm.d.mts → SandboxError-CIrVDOMV.d.mts} +2 -2
  12. package/dist/{SandboxError-BvKypkJm.d.mts.map → SandboxError-CIrVDOMV.d.mts.map} +1 -1
  13. package/dist/{StructuredFormat-BbN4dosH.d.mts → StructuredFormat-DHaUJBdd.d.mts} +2 -2
  14. package/dist/{StructuredFormat-BbN4dosH.d.mts.map → StructuredFormat-DHaUJBdd.d.mts.map} +1 -1
  15. package/dist/Tool-2lC_XMth.d.mts +284 -0
  16. package/dist/Tool-2lC_XMth.d.mts.map +1 -0
  17. package/dist/ToolEvent-cWZ2BnbS.d.mts +151 -0
  18. package/dist/ToolEvent-cWZ2BnbS.d.mts.map +1 -0
  19. package/dist/ToolResult-CL4hK_9z.d.mts +157 -0
  20. package/dist/ToolResult-CL4hK_9z.d.mts.map +1 -0
  21. package/dist/Toolkit-Dr9l_hNy.d.mts +196 -0
  22. package/dist/Toolkit-Dr9l_hNy.d.mts.map +1 -0
  23. package/dist/browser/Browser.d.mts +190 -0
  24. package/dist/browser/Browser.d.mts.map +1 -0
  25. package/dist/browser/Browser.mjs +34 -0
  26. package/dist/browser/Browser.mjs.map +1 -0
  27. package/dist/browser/BrowserError.d.mts +141 -0
  28. package/dist/browser/BrowserError.d.mts.map +1 -0
  29. package/dist/browser/BrowserError.mjs +89 -0
  30. package/dist/browser/BrowserError.mjs.map +1 -0
  31. package/dist/browser/BrowserTool.d.mts +58 -0
  32. package/dist/browser/BrowserTool.d.mts.map +1 -0
  33. package/dist/browser/BrowserTool.mjs +86 -0
  34. package/dist/browser/BrowserTool.mjs.map +1 -0
  35. package/dist/{dist-DV5ISja1.mjs → dist-B04dlFHg.mjs} +3 -3
  36. package/dist/{dist-DV5ISja1.mjs.map → dist-B04dlFHg.mjs.map} +1 -1
  37. package/dist/dist-DWgRbDN3.mjs +140 -0
  38. package/dist/dist-DWgRbDN3.mjs.map +1 -0
  39. package/dist/domain/AiError.d.mts +2 -2
  40. package/dist/domain/AiError.mjs +22 -3
  41. package/dist/domain/AiError.mjs.map +1 -1
  42. package/dist/domain/Audio.d.mts +1 -1
  43. package/dist/domain/Image.d.mts +1 -1
  44. package/dist/domain/Items.d.mts +1 -1
  45. package/dist/domain/Music.d.mts +1 -1
  46. package/dist/domain/Turn.d.mts +4 -4
  47. package/dist/domain/Turn.test.mjs +1 -1
  48. package/dist/embedding-model/Embedding.d.mts +1 -1
  49. package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
  50. package/dist/embedding-model/EmbeddingModel.test.mjs +1 -1
  51. package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -1
  52. package/dist/index.d.mts +19 -13
  53. package/dist/index.mjs +15 -9
  54. package/dist/language-model/LanguageModel.d.mts +10 -5
  55. package/dist/language-model/LanguageModel.d.mts.map +1 -1
  56. package/dist/language-model/LanguageModel.mjs.map +1 -1
  57. package/dist/language-model/LanguageModel.test.mjs +1 -1
  58. package/dist/loop/Loop.d.mts +2 -141
  59. package/dist/loop/Loop.mjs +16 -20
  60. package/dist/loop/Loop.mjs.map +1 -1
  61. package/dist/loop/Loop.test.mjs +18 -1
  62. package/dist/loop/Loop.test.mjs.map +1 -1
  63. package/dist/{magic-string.es-BgIV5Mu3.mjs → magic-string.es-Dk-MMA0F.mjs} +1 -1
  64. package/dist/{magic-string.es-BgIV5Mu3.mjs.map → magic-string.es-Dk-MMA0F.mjs.map} +1 -1
  65. package/dist/music-generator/MusicGenerator.d.mts +2 -2
  66. package/dist/music-generator/MusicGenerator.test.mjs +1 -1
  67. package/dist/observability/Metrics.d.mts +116 -28
  68. package/dist/observability/Metrics.d.mts.map +1 -1
  69. package/dist/observability/Metrics.mjs +250 -38
  70. package/dist/observability/Metrics.mjs.map +1 -1
  71. package/dist/observability/Metrics.test.d.mts +1 -0
  72. package/dist/observability/Metrics.test.mjs +187 -0
  73. package/dist/observability/Metrics.test.mjs.map +1 -0
  74. package/dist/observability/Telemetry.d.mts +37 -0
  75. package/dist/observability/Telemetry.d.mts.map +1 -0
  76. package/dist/observability/Telemetry.mjs +57 -0
  77. package/dist/observability/Telemetry.mjs.map +1 -0
  78. package/dist/observability/Telemetry.test.d.mts +1 -0
  79. package/dist/observability/Telemetry.test.mjs +92 -0
  80. package/dist/observability/Telemetry.test.mjs.map +1 -0
  81. package/dist/retry/Retry.d.mts +2 -2
  82. package/dist/retry/Retry.test.mjs +1 -1
  83. package/dist/sandbox/Sandbox.d.mts +1 -1
  84. package/dist/sandbox/SandboxError.d.mts +1 -1
  85. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +2 -2
  86. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +1 -1
  87. package/dist/streaming/JSONL.d.mts +1 -1
  88. package/dist/streaming/JSONL.test.mjs +1 -1
  89. package/dist/streaming/SSE.test.mjs +1 -1
  90. package/dist/structured-format/StructuredFormat.d.mts +1 -1
  91. package/dist/structured-format/StructuredFormat.mjs.map +1 -1
  92. package/dist/structured-format/StructuredFormat.test.mjs +1 -1
  93. package/dist/testing/MockMusicGenerator.d.mts +1 -1
  94. package/dist/testing/MockProvider.d.mts +1 -1
  95. package/dist/testing/MockSpeechSynthesizer.d.mts +1 -1
  96. package/dist/tool/Approval.d.mts +19 -7
  97. package/dist/tool/Approval.d.mts.map +1 -1
  98. package/dist/tool/Approval.mjs +18 -11
  99. package/dist/tool/Approval.mjs.map +1 -1
  100. package/dist/tool/Approval.test.mjs +80 -40
  101. package/dist/tool/Approval.test.mjs.map +1 -1
  102. package/dist/tool/HistoryCheck.d.mts +2 -2
  103. package/dist/tool/Tool.d.mts +2 -2
  104. package/dist/tool/Tool.mjs +190 -36
  105. package/dist/tool/Tool.mjs.map +1 -1
  106. package/dist/tool/Tool.test.mjs +41 -3
  107. package/dist/tool/Tool.test.mjs.map +1 -1
  108. package/dist/tool/ToolEvent.d.mts +2 -151
  109. package/dist/tool/ToolEvent.mjs +5 -0
  110. package/dist/tool/ToolEvent.mjs.map +1 -1
  111. package/dist/tool/ToolResult.d.mts +2 -138
  112. package/dist/tool/ToolResult.mjs +32 -8
  113. package/dist/tool/ToolResult.mjs.map +1 -1
  114. package/dist/tool/Toolkit.d.mts +2 -73
  115. package/dist/tool/Toolkit.mjs +153 -35
  116. package/dist/tool/Toolkit.mjs.map +1 -1
  117. package/dist/tool/Toolkit.test.mjs +271 -11
  118. package/dist/tool/Toolkit.test.mjs.map +1 -1
  119. package/dist/transcriber/Transcriber.d.mts +2 -2
  120. package/dist/transcriber/Transcriber.test.mjs +1 -1
  121. package/dist/web-read/WebRead.d.mts +44 -0
  122. package/dist/web-read/WebRead.d.mts.map +1 -0
  123. package/dist/web-read/WebRead.mjs +18 -0
  124. package/dist/web-read/WebRead.mjs.map +1 -0
  125. package/dist/web-read/WebReadTool.d.mts +57 -0
  126. package/dist/web-read/WebReadTool.d.mts.map +1 -0
  127. package/dist/web-read/WebReadTool.mjs +59 -0
  128. package/dist/web-read/WebReadTool.mjs.map +1 -0
  129. package/dist/web-search/WebSearch.d.mts +1 -1
  130. package/dist/web-search/WebSearchTool.d.mts +12 -6
  131. package/dist/web-search/WebSearchTool.d.mts.map +1 -1
  132. package/dist/web-search/WebSearchTool.mjs +10 -5
  133. package/dist/web-search/WebSearchTool.mjs.map +1 -1
  134. package/package.json +21 -1
  135. package/src/browser/Browser.ts +268 -0
  136. package/src/browser/BrowserError.ts +145 -0
  137. package/src/browser/BrowserTool.ts +161 -0
  138. package/src/domain/AiError.ts +27 -1
  139. package/src/embedding-model/EmbeddingModel.test.ts +4 -0
  140. package/src/index.ts +6 -0
  141. package/src/language-model/LanguageModel.ts +7 -2
  142. package/src/loop/Loop.test.ts +22 -0
  143. package/src/loop/Loop.ts +27 -4
  144. package/src/observability/Metrics.test.ts +266 -0
  145. package/src/observability/Metrics.ts +505 -65
  146. package/src/observability/Telemetry.test.ts +123 -0
  147. package/src/observability/Telemetry.ts +96 -0
  148. package/src/structured-format/StructuredFormat.ts +4 -0
  149. package/src/tool/Approval.test.ts +77 -26
  150. package/src/tool/Approval.ts +72 -27
  151. package/src/tool/Tool.test.ts +55 -2
  152. package/src/tool/Tool.ts +335 -70
  153. package/src/tool/ToolEvent.ts +5 -0
  154. package/src/tool/ToolResult.ts +36 -9
  155. package/src/tool/Toolkit.test.ts +339 -9
  156. package/src/tool/Toolkit.ts +470 -120
  157. package/src/web-read/WebRead.ts +70 -0
  158. package/src/web-read/WebReadTool.ts +96 -0
  159. package/src/web-search/WebSearchTool.ts +11 -5
  160. package/dist/Tool-BKiieFXk.d.mts +0 -102
  161. package/dist/Tool-BKiieFXk.d.mts.map +0 -1
  162. package/dist/loop/Loop.d.mts.map +0 -1
  163. package/dist/tool/ToolEvent.d.mts.map +0 -1
  164. package/dist/tool/ToolResult.d.mts.map +0 -1
  165. package/dist/tool/Toolkit.d.mts.map +0 -1
@@ -0,0 +1,161 @@
1
+ import { Effect, Schema } from "effect"
2
+ import * as Tool from "../tool/Tool.js"
3
+ import * as Toolkit from "../tool/Toolkit.js"
4
+ import type { BrowserSession } from "./Browser.js"
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Model-facing argument schemas. Annotated so the descriptions reach the
8
+ // model through the rendered JSON Schema (JSDoc does not).
9
+ // ---------------------------------------------------------------------------
10
+
11
+ const GotoArgs = Schema.Struct({
12
+ url: Schema.String.annotate({ description: "Absolute URL to navigate to." }),
13
+ })
14
+
15
+ const ClickArgs = Schema.Struct({
16
+ ref: Schema.String.annotate({
17
+ description: "A CSS selector, or an element @ref if a page-reading tool provides one.",
18
+ }),
19
+ })
20
+
21
+ const FillArgs = Schema.Struct({
22
+ ref: Schema.String.annotate({
23
+ description: "A CSS selector (or element @ref) of the input to fill.",
24
+ }),
25
+ text: Schema.String.annotate({ description: "The text to put in the input." }),
26
+ })
27
+
28
+ const PressArgs = Schema.Struct({
29
+ key: Schema.String.annotate({
30
+ description: 'Key to press, e.g. "Enter" to submit the focused input, "Tab", "Escape".',
31
+ }),
32
+ })
33
+
34
+ const ScrollArgs = Schema.Struct({
35
+ direction: Schema.Literals(["up", "down"]).annotate({
36
+ description: "Scroll the viewport up or down.",
37
+ }),
38
+ })
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Uniform action outcome: the model always learns where the action left it.
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const currentUrl = (session: BrowserSession): Effect.Effect<string> =>
45
+ session.evaluate("location.href").pipe(
46
+ Effect.flatMap(Schema.decodeUnknownEffect(Schema.String)),
47
+ Effect.orElseSucceed(() => "(unknown)"),
48
+ )
49
+
50
+ const okAt = (session: BrowserSession): Effect.Effect<string> =>
51
+ Effect.map(currentUrl(session), (url) => `ok (now at ${url})`)
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // The canonical browser action tools, each closed over a live `BrowserSession`
55
+ // and failing with the full `BrowserError`. `Output` is a compact outcome
56
+ // (`ok (now at <url>)`), not a page dump - a page-reading tool is the recipe's
57
+ // to define, so history does not accumulate a full page per action.
58
+ //
59
+ // A failed action (stale ref, dead control, timeout) fails typed on the error
60
+ // channel. Whether the model gets to recover from it is the loop's decision:
61
+ // wrap the toolkit in `Toolkit.describeFailures(BrowserError.describe)` to
62
+ // surface failures to the model, or map selectively to keep session/infra
63
+ // errors (e.g. `BrowserSessionExpired`) fatal.
64
+ //
65
+ // Browser actions are order-dependent (fill, then press Enter): run a turn's
66
+ // calls with `{ concurrency: 1 }`.
67
+ // ---------------------------------------------------------------------------
68
+
69
+ export const gotoTool = (session: BrowserSession) =>
70
+ Tool.make({
71
+ name: "browser_goto",
72
+ description: "Navigate the browser to an absolute URL.",
73
+ inputSchema: Tool.fromEffectSchema(GotoArgs),
74
+ run: (args) =>
75
+ Effect.andThen(session.goto(args.url), okAt(session)).pipe(
76
+ Effect.withSpan("browser_goto", {
77
+ kind: "client",
78
+ attributes: { "browser.url": args.url },
79
+ }),
80
+ ),
81
+ })
82
+
83
+ export const clickTool = (session: BrowserSession) =>
84
+ Tool.make({
85
+ name: "browser_click",
86
+ description: "Click the element named by ref. A click may navigate to a new page.",
87
+ inputSchema: Tool.fromEffectSchema(ClickArgs),
88
+ run: (args) =>
89
+ Effect.andThen(session.click(args.ref), okAt(session)).pipe(
90
+ Effect.withSpan("browser_click", {
91
+ kind: "client",
92
+ attributes: { "browser.ref": args.ref },
93
+ }),
94
+ ),
95
+ })
96
+
97
+ export const fillTool = (session: BrowserSession) =>
98
+ Tool.make({
99
+ name: "browser_fill",
100
+ description:
101
+ "Put text into the input named by ref. Filling alone does not submit; press Enter to submit the input.",
102
+ inputSchema: Tool.fromEffectSchema(FillArgs),
103
+ run: (args) =>
104
+ Effect.andThen(session.fill(args.ref, args.text), okAt(session)).pipe(
105
+ Effect.withSpan("browser_fill", {
106
+ kind: "client",
107
+ attributes: { "browser.ref": args.ref },
108
+ }),
109
+ ),
110
+ })
111
+
112
+ export const pressTool = (session: BrowserSession) =>
113
+ Tool.make({
114
+ name: "browser_press",
115
+ description: "Press a keyboard key against the focused element.",
116
+ inputSchema: Tool.fromEffectSchema(PressArgs),
117
+ run: (args) =>
118
+ Effect.andThen(session.press(args.key), okAt(session)).pipe(
119
+ Effect.withSpan("browser_press", {
120
+ kind: "client",
121
+ attributes: { "browser.key": args.key },
122
+ }),
123
+ ),
124
+ })
125
+
126
+ export const scrollTool = (session: BrowserSession) =>
127
+ Tool.make({
128
+ name: "browser_scroll",
129
+ description:
130
+ "Scroll the viewport. Only useful to bring an element into view - a page-reading tool's text is already scroll-independent.",
131
+ inputSchema: Tool.fromEffectSchema(ScrollArgs),
132
+ run: (args) =>
133
+ Effect.andThen(session.scroll({ direction: args.direction }), okAt(session)).pipe(
134
+ Effect.withSpan("browser_scroll", {
135
+ kind: "client",
136
+ attributes: { "browser.direction": args.direction },
137
+ }),
138
+ ),
139
+ })
140
+
141
+ /**
142
+ * The canonical browser action verbs bundled as a `Toolkit`, closed over a
143
+ * live `BrowserSession`. Compose it with a recipe-local page-reading tool and
144
+ * whatever finish/signal tools the agent needs:
145
+ *
146
+ * const toolkit = yield* Toolkit.compose(
147
+ * browserToolkit(session),
148
+ * Toolkit.make(readPageTool(session), finishTool),
149
+ * )
150
+ *
151
+ * For an à-la-carte subset, use the individual `gotoTool` / `clickTool` / …
152
+ * constructors directly. Run a turn's calls with `{ concurrency: 1 }`.
153
+ */
154
+ export const browserToolkit = (session: BrowserSession) =>
155
+ Toolkit.make(
156
+ gotoTool(session),
157
+ clickTool(session),
158
+ fillTool(session),
159
+ pressTool(session),
160
+ scrollTool(session),
161
+ )
@@ -1,4 +1,4 @@
1
- import { Data, Duration } from "effect"
1
+ import { Data, Duration, Match } from "effect"
2
2
 
3
3
  export type Scope = "rpm" | "tpm" | "rpd" | "tpd"
4
4
 
@@ -112,3 +112,29 @@ export type AiError =
112
112
  | IncompleteTurn
113
113
  | GenerationFailed
114
114
  | Unsupported
115
+
116
+ const withReason = (base: string, reason: string | undefined): string =>
117
+ reason === undefined ? base : `${base}: ${reason}`
118
+
119
+ /**
120
+ * Short human-readable description of an error, for logs and model-facing
121
+ * failure messages. Prose, not a contract; branch on `_tag` instead.
122
+ */
123
+ export const describe: (e: AiError) => string = Match.type<AiError>().pipe(
124
+ Match.discriminatorsExhaustive("_tag")({
125
+ RateLimited: (e) => `rate limited by ${e.provider}; try again later`,
126
+ Unavailable: (e) => `${e.provider} is temporarily unavailable`,
127
+ Timeout: (e) => `the ${e.provider} request timed out`,
128
+ ContentFiltered: (e) => withReason(`blocked by ${e.provider}'s content filter`, e.reason),
129
+ ContextLengthExceeded: (e) => `the request exceeded ${e.provider}'s context limit`,
130
+ InvalidRequest: (e) =>
131
+ e.param === undefined
132
+ ? `${e.provider} rejected the request`
133
+ : `${e.provider} rejected the request (param: ${e.param})`,
134
+ AuthFailed: (e) => `authentication against ${e.provider} failed (${e.subtype})`,
135
+ Cancelled: (e) => `the ${e.provider} request was cancelled`,
136
+ IncompleteTurn: () => "the provider stream ended without completing",
137
+ GenerationFailed: (e) => e.message ?? `${e.provider} failed mid-generation`,
138
+ Unsupported: (e) => withReason(`${e.provider} does not support ${e.capability}`, e.reason),
139
+ }),
140
+ )
@@ -81,10 +81,14 @@ describe("embed / embedMany free exports preserve E in their return type", () =>
81
81
  // of the cross-provider request set. Requesting them on the generic
82
82
  // helper is a compile error; they're reachable only via the typed
83
83
  // `JinaEmbedding` tag.
84
+ // These calls exist only to assert the encoding is a compile error; they
85
+ // are intentionally never run, so the floating-effect check is off here.
86
+ // @effect-diagnostics effect/floatingEffect:off
84
87
  // @ts-expect-error "multivector" is not an EmbedEncoding
85
88
  embedMany({ inputs: ["x"], model: "m", encoding: "multivector" })
86
89
  // @ts-expect-error "sparse" is not an EmbedEncoding
87
90
  embed({ input: "x", model: "m", encoding: "sparse" })
91
+ // @effect-diagnostics effect/floatingEffect:error
88
92
  })
89
93
 
90
94
  it("when the request is widened to CommonEmbedRequest, the response is the open union", () => {
package/src/index.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export * as AiError from "./domain/AiError.js"
2
2
  export * as Audio from "./domain/Audio.js"
3
+ export * as Browser from "./browser/Browser.js"
4
+ export * as BrowserError from "./browser/BrowserError.js"
5
+ export * as BrowserTool from "./browser/BrowserTool.js"
3
6
  export * as Capabilities from "./capabilities/Capabilities.js"
4
7
  export * as Image from "./domain/Image.js"
5
8
  export * as Items from "./domain/Items.js"
@@ -18,6 +21,8 @@ export * as SandboxImage from "./sandbox/SandboxImage.js"
18
21
  export * as SandboxNetwork from "./sandbox/SandboxNetwork.js"
19
22
  export * as SpeechSynthesizer from "./speech-synthesizer/SpeechSynthesizer.js"
20
23
  export * as Transcriber from "./transcriber/Transcriber.js"
24
+ export * as WebRead from "./web-read/WebRead.js"
25
+ export * as WebReadTool from "./web-read/WebReadTool.js"
21
26
  export * as WebSearch from "./web-search/WebSearch.js"
22
27
  export * as WebSearchTool from "./web-search/WebSearchTool.js"
23
28
  export * as Vector from "./math/Vector.js"
@@ -34,3 +39,4 @@ export * as Lines from "./streaming/Lines.js"
34
39
  export * as SSE from "./streaming/SSE.js"
35
40
  export * as StructuredFormat from "./structured-format/StructuredFormat.js"
36
41
  export * as Metrics from "./observability/Metrics.js"
42
+ export * as Telemetry from "./observability/Telemetry.js"
@@ -2,7 +2,7 @@ import { Array as Arr, Context, Effect, Option, Stream } from "effect"
2
2
  import * as AiError from "../domain/AiError.js"
3
3
  import type { HistoryItem } from "../domain/Items.js"
4
4
  import type * as StructuredFormat from "../structured-format/StructuredFormat.js"
5
- import type { ToolDescriptor } from "../tool/Tool.js"
5
+ import type { Toolkit } from "../tool/Toolkit.js"
6
6
  import { isTurnComplete, type Turn, type TurnEvent } from "../domain/Turn.js"
7
7
 
8
8
  /**
@@ -18,7 +18,12 @@ export type CommonRequest = {
18
18
  * so code that yields a typed provider tag gets autocompletion.
19
19
  */
20
20
  readonly model: string
21
- readonly tools?: ReadonlyArray<ToolDescriptor>
21
+ /**
22
+ * The model-visible tools for this request, as a `Toolkit`. The provider
23
+ * renders it to wire descriptors (`Tool.descriptorsOf`); omit it for a turn
24
+ * with no tools.
25
+ */
26
+ readonly tools?: Toolkit
22
27
  readonly toolChoice?:
23
28
  | "auto"
24
29
  | "required"
@@ -179,6 +179,28 @@ describe("Loop.loop", () => {
179
179
  expect(b.length).toBe(3)
180
180
  })
181
181
 
182
+ it("onTurnComplete: handler may return a step stream directly (not just Effect<Stream>)", async () => {
183
+ // The handler can skip the Effect wrapper when no effect is needed -
184
+ // bare stop() / next(s), mirroring loop's body.
185
+ const turnComplete: TurnEvent = TurnEvent.TurnComplete({
186
+ turn: { items: [], usage: { input_tokens: 0, output_tokens: 0 }, stop_reason: "stop" },
187
+ })
188
+ const deltas: Stream.Stream<TurnEvent> = Stream.fromIterable([
189
+ TurnEvent.TextDelta({ text: "hi" }),
190
+ turnComplete,
191
+ ])
192
+
193
+ const plain = deltas.pipe(onTurnComplete(() => stop()))
194
+ const effectful = deltas.pipe(onTurnComplete(() => Effect.sync(() => stop())))
195
+
196
+ const a = await Effect.runPromise(Stream.runCollect(plain))
197
+ const b = await Effect.runPromise(Stream.runCollect(effectful))
198
+
199
+ // Two value(delta) wraps + one stop sentinel; both return forms behave identically.
200
+ expect(a.length).toBe(3)
201
+ expect(b.length).toBe(3)
202
+ })
203
+
182
204
  it("is stack-safe and linear-time across many iterations", async () => {
183
205
  // 100k iterations far exceeds V8's typical stack depth (~10–15k frames).
184
206
  const N = 100_000
package/src/loop/Loop.ts CHANGED
@@ -67,6 +67,16 @@ interface StepDef extends Data.TaggedEnum.WithGenerics<2> {
67
67
 
68
68
  const Step = Data.taggedEnum<StepDef>()
69
69
 
70
+ /**
71
+ * Bare step constructors, so a loop body can emit `Value(...)`, `Next(...)`,
72
+ * `Stop()`, `StopWith(...)` directly inside a `Stream.make(...)` without the
73
+ * `Step.` prefix. (The `Step` object itself can't be exported under that name
74
+ * because it collides with the exported `Step` type.) For the common cases
75
+ * prefer the `value` / `next` / `stop` helpers, which also handle the
76
+ * single-element-stream wrapping for `next` / `stop`.
77
+ */
78
+ export const { Value, Next, Stop, StopWith } = Step
79
+
70
80
  /** Wrap a value so it flows through the loop to downstream consumers. */
71
81
  export const value = <A>(a: A): Step<A, never> => Step.Value({ value: a })
72
82
 
@@ -103,6 +113,11 @@ export const stop = <S = never>(state?: S): Stream.Stream<Step<never, S>> =>
103
113
  * Pre-pipe transforms (`Stream.tap` / `Stream.map` / `Stream.filter`) on
104
114
  * the raw delta stream cover anything an `emit`-style callback would do.
105
115
  *
116
+ * `then` may return the step stream directly, or an `Effect` that produces
117
+ * it (for branches that need to read a `Ref`, decode tool args, log, etc.).
118
+ * Mirrors `loop`'s body: return a bare `stop()` / `next(s)` where no effect
119
+ * is needed, lift to `Effect` only where one is.
120
+ *
106
121
  * If the upstream ends without a `TurnComplete`, the resulting stream
107
122
  * fails with `AiError.IncompleteTurn`. Catch it via `Stream.catchTag` if
108
123
  * you want to recover.
@@ -110,21 +125,25 @@ export const stop = <S = never>(state?: S): Stream.Stream<Step<never, S>> =>
110
125
  * Dual: data-first `onTurnComplete(deltas, then)` and data-last
111
126
  * `deltas.pipe(onTurnComplete(then))` both work.
112
127
  */
128
+ type TurnContinuation<S, A, E2, R2> =
129
+ | Stream.Stream<Step<A, S>, E2, R2>
130
+ | Effect.Effect<Stream.Stream<Step<A, S>, E2, R2>, E2, R2>
131
+
113
132
  export const onTurnComplete: {
114
133
  <S, A, E2 = never, R2 = never>(
115
- then: (turn: Turn) => Effect.Effect<Stream.Stream<Step<A, S>, E2, R2>, E2, R2>,
134
+ then: (turn: Turn) => TurnContinuation<S, A, E2, R2>,
116
135
  ): <E, R>(
117
136
  deltas: Stream.Stream<TurnEvent, E, R>,
118
137
  ) => Stream.Stream<Step<TurnEvent | A, S>, E | E2 | IncompleteTurn, R | R2>
119
138
  <S, A, E, R, E2 = never, R2 = never>(
120
139
  deltas: Stream.Stream<TurnEvent, E, R>,
121
- then: (turn: Turn) => Effect.Effect<Stream.Stream<Step<A, S>, E2, R2>, E2, R2>,
140
+ then: (turn: Turn) => TurnContinuation<S, A, E2, R2>,
122
141
  ): Stream.Stream<Step<TurnEvent | A, S>, E | E2 | IncompleteTurn, R | R2>
123
142
  } = Function.dual(
124
143
  2,
125
144
  <S, A, E, R, E2, R2>(
126
145
  deltas: Stream.Stream<TurnEvent, E, R>,
127
- then: (turn: Turn) => Effect.Effect<Stream.Stream<Step<A, S>, E2, R2>, E2, R2>,
146
+ then: (turn: Turn) => TurnContinuation<S, A, E2, R2>,
128
147
  ): Stream.Stream<Step<TurnEvent | A, S>, E | E2 | IncompleteTurn, R | R2> =>
129
148
  Stream.unwrap(
130
149
  Effect.gen(function* () {
@@ -141,7 +160,8 @@ export const onTurnComplete: {
141
160
  Effect.gen(function* () {
142
161
  const opt = yield* Ref.get(turnRef)
143
162
  if (Option.isNone(opt)) return yield* new IncompleteTurn({})
144
- return yield* then(opt.value)
163
+ const result = then(opt.value)
164
+ return Effect.isEffect(result) ? yield* result : result
145
165
  }),
146
166
  )
147
167
 
@@ -287,6 +307,9 @@ export const loop: {
287
307
  }
288
308
  })
289
309
 
310
+ // `Stream.fromPull` expects the generator to RETURN the pull effect,
311
+ // not run it; `return yield* pull` would execute a pull and break it.
312
+ // @effect-diagnostics-next-line effect/returnEffectInGen:off
290
313
  return pull
291
314
  }),
292
315
  ),
@@ -0,0 +1,266 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import { Array as Arr, Duration, Effect, Fiber, Option, Stream } from "effect"
3
+ import * as TestClock from "effect/testing/TestClock"
4
+ import { expect } from "vitest"
5
+ import type { Usage } from "../domain/Items.js"
6
+ import { TurnEvent } from "../domain/Turn.js"
7
+ import {
8
+ type MetricEvent,
9
+ type Throughput,
10
+ type TimeToCompletion,
11
+ type TimeToFirstToken,
12
+ type TokenTotals,
13
+ computeThroughputTick,
14
+ isMetricEvent,
15
+ makeEvent,
16
+ metricEvents,
17
+ throughput,
18
+ timeToCompletion,
19
+ timeToFirstToken,
20
+ tokenTotals,
21
+ } from "./Metrics.js"
22
+
23
+ const turnComplete = (usage: Usage): TurnEvent =>
24
+ TurnEvent.TurnComplete({ turn: { items: [], usage, stop_reason: "stop" } })
25
+
26
+ const text = (s: string): TurnEvent => TurnEvent.TextDelta({ text: s })
27
+
28
+ const samples = (events: ReadonlyArray<unknown>): ReadonlyArray<MetricEvent> =>
29
+ events.filter(isMetricEvent)
30
+
31
+ const head = <A>(xs: ReadonlyArray<A>): A => Option.getOrThrow(Arr.head(xs))
32
+
33
+ const tagged = <T extends MetricEvent["_tag"]>(
34
+ events: ReadonlyArray<unknown>,
35
+ tag: T,
36
+ ): ReadonlyArray<Extract<MetricEvent, { readonly _tag: T }>> =>
37
+ samples(events).filter((e): e is Extract<MetricEvent, { readonly _tag: T }> => e._tag === tag)
38
+
39
+ // A stream that emits each event after the given delay since the previous one.
40
+ const timed = <A>(steps: ReadonlyArray<readonly [Duration.Input, A]>): Stream.Stream<A> =>
41
+ Stream.fromIterable(steps).pipe(
42
+ Stream.mapEffect(([delay, ev]) => Effect.as(Effect.sleep(delay), ev)),
43
+ )
44
+
45
+ // Collect a metered stream while advancing the test clock by each step, so
46
+ // every per-event clock read lands at a known virtual time.
47
+ const runTimed = <A>(
48
+ metered: Stream.Stream<A>,
49
+ steps: ReadonlyArray<Duration.Input>,
50
+ ): Effect.Effect<ReadonlyArray<A>> =>
51
+ Effect.gen(function* () {
52
+ const fiber = yield* Effect.forkChild(Stream.runCollect(metered))
53
+ yield* Effect.forEach(steps, (step) => TestClock.adjust(step), { discard: true })
54
+ return yield* Fiber.join(fiber)
55
+ })
56
+
57
+ describe("tokenTotals", () => {
58
+ it.effect("turn level: a single TurnComplete reports that generation's usage", () =>
59
+ Effect.gen(function* () {
60
+ const stream = Stream.make(
61
+ text("hi"),
62
+ turnComplete({ input_tokens: 100, output_tokens: 20, total_tokens: 120 }),
63
+ )
64
+ const out = yield* Stream.runCollect(stream.pipe(tokenTotals))
65
+ const totals = tagged(out, "TokenTotals") as ReadonlyArray<TokenTotals>
66
+ expect(totals).toHaveLength(1)
67
+ expect(head(totals).turnIndex).toBe(0)
68
+ expect(head(totals).usage.output_tokens).toBe(20)
69
+ expect(head(totals).cumulative.output_tokens).toBe(20)
70
+ }),
71
+ )
72
+
73
+ it.effect("loop level: cumulative sums across every turn, usage stays per-turn", () =>
74
+ Effect.gen(function* () {
75
+ const stream = Stream.make(
76
+ text("a"),
77
+ turnComplete({ input_tokens: 1000, output_tokens: 120 }),
78
+ turnComplete({ input_tokens: 1300, output_tokens: 410 }),
79
+ )
80
+ const out = yield* Stream.runCollect(stream.pipe(tokenTotals))
81
+ const totals = tagged(out, "TokenTotals") as ReadonlyArray<TokenTotals>
82
+ expect(totals.map((t) => t.turnIndex)).toEqual([0, 1])
83
+ expect(totals.map((t) => t.usage.output_tokens)).toEqual([120, 410])
84
+ expect(totals.map((t) => t.cumulative.input_tokens)).toEqual([1000, 2300])
85
+ expect(totals.map((t) => t.cumulative.output_tokens)).toEqual([120, 530])
86
+ }),
87
+ )
88
+
89
+ it.effect("emits an incremental output_tokens counter measurement per turn", () =>
90
+ Effect.gen(function* () {
91
+ const stream = Stream.make(turnComplete({ output_tokens: 7 }))
92
+ const out = yield* Stream.runCollect(stream.pipe(tokenTotals))
93
+ const m = head(samples(out)).measurements.find((m) => m.name === "effect_uai_output_tokens")
94
+ expect(m).toEqual({ name: "effect_uai_output_tokens", kind: "counter", value: 7 })
95
+ }),
96
+ )
97
+ })
98
+
99
+ describe("timeToFirstToken", () => {
100
+ it.effect("fires on the first content delta, measured from request (stream init)", () =>
101
+ Effect.gen(function* () {
102
+ const stream = timed<TurnEvent>([
103
+ ["300 millis", text("hello")],
104
+ ["700 millis", turnComplete({})],
105
+ ]).pipe(timeToFirstToken())
106
+ const out = yield* runTimed(stream, ["300 millis", "700 millis"])
107
+ const ttft = tagged(out, "TimeToFirstToken") as ReadonlyArray<TimeToFirstToken>
108
+ expect(ttft).toHaveLength(1)
109
+ expect(Duration.toMillis(head(ttft).elapsed)).toBe(300)
110
+ expect(head(ttft).kind).toBe("text")
111
+ }),
112
+ )
113
+
114
+ it.effect("reports kind reasoning when reasoning arrives first", () =>
115
+ Effect.gen(function* () {
116
+ const stream = timed<TurnEvent>([
117
+ ["120 millis", TurnEvent.ReasoningDelta({ text: "...", kind: "trace" })],
118
+ ["80 millis", text("answer")],
119
+ ["50 millis", turnComplete({})],
120
+ ]).pipe(timeToFirstToken())
121
+ const out = yield* runTimed(stream, ["120 millis", "80 millis", "50 millis"])
122
+ const ttft = tagged(out, "TimeToFirstToken") as ReadonlyArray<TimeToFirstToken>
123
+ expect(ttft).toHaveLength(1)
124
+ expect(head(ttft).kind).toBe("reasoning")
125
+ expect(Duration.toMillis(head(ttft).elapsed)).toBe(120)
126
+ }),
127
+ )
128
+
129
+ it.effect("loop level: one TTFT per turn, turnIndex increments", () =>
130
+ Effect.gen(function* () {
131
+ const stream = timed<TurnEvent>([
132
+ ["200 millis", text("a")],
133
+ ["100 millis", turnComplete({})],
134
+ ["300 millis", text("b")],
135
+ ["100 millis", turnComplete({})],
136
+ ]).pipe(timeToFirstToken())
137
+ const out = yield* runTimed(stream, ["200 millis", "100 millis", "300 millis", "100 millis"])
138
+ const ttft = tagged(out, "TimeToFirstToken") as ReadonlyArray<TimeToFirstToken>
139
+ expect(ttft.map((t) => t.turnIndex)).toEqual([0, 1])
140
+ // turn 0 from init; turn 1 from the previous TurnComplete (loop-scope approximation)
141
+ expect(ttft.map((t) => Duration.toMillis(t.elapsed))).toEqual([200, 300])
142
+ }),
143
+ )
144
+ })
145
+
146
+ describe("timeToCompletion", () => {
147
+ it.effect("reports duration (request->complete) and generation (first token->complete)", () =>
148
+ Effect.gen(function* () {
149
+ const stream = timed<TurnEvent>([
150
+ ["300 millis", text("hi")],
151
+ ["700 millis", turnComplete({})],
152
+ ]).pipe(timeToCompletion)
153
+ const out = yield* runTimed(stream, ["300 millis", "700 millis"])
154
+ const done = tagged(out, "TimeToCompletion") as ReadonlyArray<TimeToCompletion>
155
+ expect(done).toHaveLength(1)
156
+ expect(Duration.toMillis(head(done).duration)).toBe(1000)
157
+ expect(Duration.toMillis(head(done).generation)).toBe(700)
158
+ }),
159
+ )
160
+ })
161
+
162
+ describe("computeThroughputTick", () => {
163
+ const base = {
164
+ units: 0,
165
+ lastUnits: 0,
166
+ lastMillis: 0,
167
+ firstMillis: Option.none<number>(),
168
+ smoothed: Option.none<number>(),
169
+ turnIndex: 0,
170
+ }
171
+
172
+ it("returns None before the first token", () => {
173
+ expect(Option.isNone(computeThroughputTick(base, 500, "windowed", undefined))).toBe(true)
174
+ })
175
+
176
+ it("windowed: rate is units in the last interval over that interval", () => {
177
+ const state = {
178
+ ...base,
179
+ units: 30,
180
+ lastUnits: 10,
181
+ lastMillis: 500,
182
+ firstMillis: Option.some(100),
183
+ }
184
+ const r = computeThroughputTick(state, 1000, "windowed", undefined)
185
+ // (30 - 10) units over (1000 - 500) ms = 40/s
186
+ expect(Option.getOrThrow(r).rate).toBe(40)
187
+ })
188
+
189
+ it("cumulative: rate is total units over time since first token", () => {
190
+ const state = {
191
+ ...base,
192
+ units: 30,
193
+ lastUnits: 10,
194
+ lastMillis: 500,
195
+ firstMillis: Option.some(100),
196
+ }
197
+ const r = computeThroughputTick(state, 1000, "cumulative", undefined)
198
+ // 30 units over (1000 - 100) ms ~= 33.33/s
199
+ expect(Option.getOrThrow(r).rate).toBeCloseTo(33.333, 2)
200
+ })
201
+
202
+ it("smooth: blends the instantaneous rate with the carried average", () => {
203
+ const state = {
204
+ ...base,
205
+ units: 20,
206
+ lastUnits: 0,
207
+ lastMillis: 0,
208
+ firstMillis: Option.some(0),
209
+ smoothed: Option.some(100),
210
+ }
211
+ // windowed instant = 20 units / 1000 ms = 20/s; smoothed = 0.5*20 + 0.5*100 = 60
212
+ const r = computeThroughputTick(state, 1000, "windowed", 0.5)
213
+ expect(Option.getOrThrow(r).rate).toBe(60)
214
+ })
215
+ })
216
+
217
+ describe("throughput (metronome)", () => {
218
+ it.effect("emits a live char rate on the tick, over the elapsed window", () =>
219
+ Effect.gen(function* () {
220
+ // 10 chars land at t=0; the source then stays open so the metronome can
221
+ // tick. take(1) ends the stream on the first sample, so termination does
222
+ // not depend on the source closing.
223
+ const source = Stream.make(text("aaaaa"), text("aaaaa")).pipe(
224
+ Stream.concat(timed<TurnEvent>([["10 seconds", turnComplete({})]])),
225
+ )
226
+ const stream = source.pipe(
227
+ throughput({ every: "1 second", unit: "char" }),
228
+ metricEvents,
229
+ Stream.take(1),
230
+ )
231
+ const out = yield* runTimed(stream, ["1 second"])
232
+ const rates = out.filter((e): e is Throughput => e._tag === "Throughput")
233
+ expect(rates).toHaveLength(1)
234
+ expect(head(rates).unit).toBe("char")
235
+ expect(head(rates).turnIndex).toBe(0)
236
+ // exact rate math is pinned by the computeThroughputTick tests; here we
237
+ // only assert the metronome wired a positive char rate onto the tick.
238
+ expect(head(rates).ratePerSecond).toBeGreaterThan(0)
239
+ }),
240
+ )
241
+ })
242
+
243
+ describe("custom events and helpers", () => {
244
+ it("makeEvent stamps the brand so isMetricEvent and the recorder see it", () => {
245
+ const ev = makeEvent({
246
+ _tag: "ToolLatency",
247
+ turnIndex: 0,
248
+ measurements: [{ name: "myapp_tool_latency", kind: "timer", value: Duration.millis(42) }],
249
+ })
250
+ expect(isMetricEvent(ev)).toBe(true)
251
+ expect(isMetricEvent(text("x"))).toBe(false)
252
+ expect(isMetricEvent(turnComplete({}))).toBe(false)
253
+ })
254
+
255
+ it.effect("metricEvents projects a mixed stream onto just the samples", () =>
256
+ Effect.gen(function* () {
257
+ const stream = Stream.make(text("hi"), turnComplete({ output_tokens: 3 })).pipe(
258
+ tokenTotals,
259
+ metricEvents,
260
+ )
261
+ const out = yield* Stream.runCollect(stream)
262
+ expect(out.every(isMetricEvent)).toBe(true)
263
+ expect(out.map((e) => e._tag)).toEqual(["TokenTotals"])
264
+ }),
265
+ )
266
+ })