@effect-uai/core 0.14.0 → 0.15.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.
- package/dist/MessengerError-CeK6VAcu.d.mts +64 -0
- package/dist/MessengerError-CeK6VAcu.d.mts.map +1 -0
- package/dist/inbox/Inbox.d.mts +16 -0
- package/dist/inbox/Inbox.d.mts.map +1 -0
- package/dist/inbox/Inbox.mjs +28 -0
- package/dist/inbox/Inbox.mjs.map +1 -0
- package/dist/inbox/Inbox.test.d.mts +1 -0
- package/dist/inbox/Inbox.test.mjs +42 -0
- package/dist/inbox/Inbox.test.mjs.map +1 -0
- package/dist/index.d.mts +5 -1
- package/dist/index.mjs +5 -1
- package/dist/messenger/Messenger.d.mts +472 -0
- package/dist/messenger/Messenger.d.mts.map +1 -0
- package/dist/messenger/Messenger.mjs +66 -0
- package/dist/messenger/Messenger.mjs.map +1 -0
- package/dist/messenger/MessengerAdapter.d.mts +35 -0
- package/dist/messenger/MessengerAdapter.d.mts.map +1 -0
- package/dist/messenger/MessengerAdapter.mjs +102 -0
- package/dist/messenger/MessengerAdapter.mjs.map +1 -0
- package/dist/messenger/MessengerAdapter.test.d.mts +1 -0
- package/dist/messenger/MessengerAdapter.test.mjs +138 -0
- package/dist/messenger/MessengerAdapter.test.mjs.map +1 -0
- package/dist/messenger/MessengerError.d.mts +2 -0
- package/dist/messenger/MessengerError.mjs +39 -0
- package/dist/messenger/MessengerError.mjs.map +1 -0
- package/dist/testing/MockMessenger.d.mts +192 -0
- package/dist/testing/MockMessenger.d.mts.map +1 -0
- package/dist/testing/MockMessenger.mjs +76 -0
- package/dist/testing/MockMessenger.mjs.map +1 -0
- package/package.json +21 -1
- package/src/inbox/Inbox.test.ts +52 -0
- package/src/inbox/Inbox.ts +33 -0
- package/src/index.ts +4 -0
- package/src/messenger/Messenger.ts +293 -0
- package/src/messenger/MessengerAdapter.test.ts +179 -0
- package/src/messenger/MessengerAdapter.ts +189 -0
- package/src/messenger/MessengerError.ts +67 -0
- package/src/testing/MockMessenger.ts +147 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-uai/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Low-level primitives (loop, conversation, items, tools, streaming codecs) for building AI agents with Effect.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -114,6 +114,18 @@
|
|
|
114
114
|
"types": "./dist/language-model/LanguageModel.d.mts",
|
|
115
115
|
"import": "./dist/language-model/LanguageModel.mjs"
|
|
116
116
|
},
|
|
117
|
+
"./Messenger": {
|
|
118
|
+
"types": "./dist/messenger/Messenger.d.mts",
|
|
119
|
+
"import": "./dist/messenger/Messenger.mjs"
|
|
120
|
+
},
|
|
121
|
+
"./MessengerAdapter": {
|
|
122
|
+
"types": "./dist/messenger/MessengerAdapter.d.mts",
|
|
123
|
+
"import": "./dist/messenger/MessengerAdapter.mjs"
|
|
124
|
+
},
|
|
125
|
+
"./MessengerError": {
|
|
126
|
+
"types": "./dist/messenger/MessengerError.d.mts",
|
|
127
|
+
"import": "./dist/messenger/MessengerError.mjs"
|
|
128
|
+
},
|
|
117
129
|
"./MusicGenerator": {
|
|
118
130
|
"types": "./dist/music-generator/MusicGenerator.d.mts",
|
|
119
131
|
"import": "./dist/music-generator/MusicGenerator.mjs"
|
|
@@ -166,6 +178,10 @@
|
|
|
166
178
|
"types": "./dist/web-search/WebSearchTool.d.mts",
|
|
167
179
|
"import": "./dist/web-search/WebSearchTool.mjs"
|
|
168
180
|
},
|
|
181
|
+
"./Inbox": {
|
|
182
|
+
"types": "./dist/inbox/Inbox.d.mts",
|
|
183
|
+
"import": "./dist/inbox/Inbox.mjs"
|
|
184
|
+
},
|
|
169
185
|
"./Loop": {
|
|
170
186
|
"types": "./dist/loop/Loop.d.mts",
|
|
171
187
|
"import": "./dist/loop/Loop.mjs"
|
|
@@ -238,6 +254,10 @@
|
|
|
238
254
|
"types": "./dist/testing/MockProvider.d.mts",
|
|
239
255
|
"import": "./dist/testing/MockProvider.mjs"
|
|
240
256
|
},
|
|
257
|
+
"./testing/MockMessenger": {
|
|
258
|
+
"types": "./dist/testing/MockMessenger.d.mts",
|
|
259
|
+
"import": "./dist/testing/MockMessenger.mjs"
|
|
260
|
+
},
|
|
241
261
|
"./testing/MockMusicGenerator": {
|
|
242
262
|
"types": "./dist/testing/MockMusicGenerator.d.mts",
|
|
243
263
|
"import": "./dist/testing/MockMusicGenerator.mjs"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it } from "@effect/vitest"
|
|
2
|
+
import { Effect, Fiber, Option, Queue } from "effect"
|
|
3
|
+
import { TestClock } from "effect/testing"
|
|
4
|
+
import { expect } from "vitest"
|
|
5
|
+
import { drainBurst } from "./Inbox.js"
|
|
6
|
+
|
|
7
|
+
describe("drainBurst", () => {
|
|
8
|
+
it.effect("resets the window on every arrival, so a burst outlives one settle", () =>
|
|
9
|
+
Effect.gen(function* () {
|
|
10
|
+
const queue = yield* Queue.unbounded<string>()
|
|
11
|
+
const burst = yield* Effect.forkChild(drainBurst(queue, "1 second"))
|
|
12
|
+
|
|
13
|
+
yield* Queue.offer(queue, "a")
|
|
14
|
+
yield* TestClock.adjust("900 millis")
|
|
15
|
+
yield* Queue.offer(queue, "b")
|
|
16
|
+
yield* TestClock.adjust("900 millis")
|
|
17
|
+
// 1.8s elapsed, more than one settle, and the burst is still open.
|
|
18
|
+
expect(burst.pollUnsafe()).toBeUndefined()
|
|
19
|
+
|
|
20
|
+
yield* TestClock.adjust("1 second")
|
|
21
|
+
expect(yield* Fiber.join(burst)).toEqual(["a", "b"])
|
|
22
|
+
}),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
it.effect("waits for the first item without a deadline", () =>
|
|
26
|
+
Effect.gen(function* () {
|
|
27
|
+
const queue = yield* Queue.unbounded<string>()
|
|
28
|
+
const burst = yield* Effect.forkChild(drainBurst(queue, "1 second"))
|
|
29
|
+
|
|
30
|
+
yield* TestClock.adjust("1 hour")
|
|
31
|
+
expect(burst.pollUnsafe()).toBeUndefined()
|
|
32
|
+
|
|
33
|
+
yield* Queue.offer(queue, "late")
|
|
34
|
+
yield* TestClock.adjust("1 second")
|
|
35
|
+
expect(yield* Fiber.join(burst)).toEqual(["late"])
|
|
36
|
+
}),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
it.effect("ends on the first quiet gap and leaves later items for the next drain", () =>
|
|
40
|
+
Effect.gen(function* () {
|
|
41
|
+
const queue = yield* Queue.unbounded<string>()
|
|
42
|
+
const burst = yield* Effect.forkChild(drainBurst(queue, "1 second"))
|
|
43
|
+
|
|
44
|
+
yield* Queue.offer(queue, "first")
|
|
45
|
+
yield* TestClock.adjust("1 second")
|
|
46
|
+
expect(yield* Fiber.join(burst)).toEqual(["first"])
|
|
47
|
+
|
|
48
|
+
yield* Queue.offer(queue, "second")
|
|
49
|
+
expect(yield* Queue.poll(queue)).toEqual(Option.some("second"))
|
|
50
|
+
}),
|
|
51
|
+
)
|
|
52
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input a long-lived agent reads between turns. A chat user rarely sends one
|
|
3
|
+
* tidy message: they type three lines in a row, then go quiet. The loop wants
|
|
4
|
+
* that as one batch, taken at a clean turn boundary.
|
|
5
|
+
*/
|
|
6
|
+
import { Array as Arr, type Duration, Effect, Option, Queue } from "effect"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Block for the first item, then keep taking while the next one lands within
|
|
10
|
+
* `settle` of the previous. The window resets on every arrival, so a burst
|
|
11
|
+
* becomes one batch and a lone message followed by silence returns at once.
|
|
12
|
+
*
|
|
13
|
+
* A take that loses to the window is interrupted before it removes anything,
|
|
14
|
+
* so late arrivals stay queued for the next drain.
|
|
15
|
+
*/
|
|
16
|
+
export const drainBurst = <A, E>(
|
|
17
|
+
queue: Queue.Dequeue<A, E>,
|
|
18
|
+
settle: Duration.Input,
|
|
19
|
+
): Effect.Effect<Arr.NonEmptyReadonlyArray<A>, E> => {
|
|
20
|
+
const settled = (
|
|
21
|
+
batch: Arr.NonEmptyReadonlyArray<A>,
|
|
22
|
+
): Effect.Effect<Arr.NonEmptyReadonlyArray<A>, E> =>
|
|
23
|
+
Queue.take(queue).pipe(
|
|
24
|
+
Effect.timeoutOption(settle),
|
|
25
|
+
Effect.flatMap(
|
|
26
|
+
Option.match({
|
|
27
|
+
onNone: () => Effect.succeed(batch),
|
|
28
|
+
onSome: (item) => settled(Arr.append(batch, item)),
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
return Effect.flatMap(Queue.take(queue), (first) => settled([first]))
|
|
33
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,9 @@ export * as Embedding from "./embedding-model/Embedding.js"
|
|
|
19
19
|
export * as EmbeddingModel from "./embedding-model/EmbeddingModel.js"
|
|
20
20
|
export * as ImageGenerator from "./image-generator/ImageGenerator.js"
|
|
21
21
|
export * as LanguageModel from "./language-model/LanguageModel.js"
|
|
22
|
+
export * as Messenger from "./messenger/Messenger.js"
|
|
23
|
+
export * as MessengerAdapter from "./messenger/MessengerAdapter.js"
|
|
24
|
+
export * as MessengerError from "./messenger/MessengerError.js"
|
|
22
25
|
export * as MusicGenerator from "./music-generator/MusicGenerator.js"
|
|
23
26
|
export * as Reranker from "./reranker/Reranker.js"
|
|
24
27
|
export * as Memory from "./sandbox/Memory.js"
|
|
@@ -34,6 +37,7 @@ export * as WebSearch from "./web-search/WebSearch.js"
|
|
|
34
37
|
export * as WebSearchTool from "./web-search/WebSearchTool.js"
|
|
35
38
|
export * as Vector from "./math/Vector.js"
|
|
36
39
|
export * as Tokenizer from "./tokenizer/Tokenizer.js"
|
|
40
|
+
export * as Inbox from "./inbox/Inbox.js"
|
|
37
41
|
export * as Loop from "./loop/Loop.js"
|
|
38
42
|
export * as Retry from "./retry/Retry.js"
|
|
39
43
|
export * as Tool from "./tool/Tool.js"
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { Brand, Context, Data, Effect, type Option, type Scope, Stream } from "effect"
|
|
2
|
+
import type { MediaSource } from "../domain/Media.js"
|
|
3
|
+
import type * as MessengerError from "./MessengerError.js"
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Identity
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
/** Where messages go. Providers mint these per their own scheme; core treats them opaquely. */
|
|
10
|
+
export type ChannelId = Brand.Branded<string, "ChannelId">
|
|
11
|
+
export const ChannelId = Brand.nominal<ChannelId>()
|
|
12
|
+
|
|
13
|
+
/** One message inside a channel. Only a named message can be edited or reacted to. */
|
|
14
|
+
export type MessageId = Brand.Branded<string, "MessageId">
|
|
15
|
+
export const MessageId = Brand.nominal<MessageId>()
|
|
16
|
+
|
|
17
|
+
/** The human (or bot) an event came from. */
|
|
18
|
+
export type UserId = Brand.Branded<string, "UserId">
|
|
19
|
+
export const UserId = Brand.nominal<UserId>()
|
|
20
|
+
|
|
21
|
+
/** A sub-conversation inside a channel, where the platform has one. Opaque, provider-minted. */
|
|
22
|
+
export type ThreadId = Brand.Branded<string, "ThreadId">
|
|
23
|
+
export const ThreadId = Brand.nominal<ThreadId>()
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Where a conversation happens. `thread` is provider-interpreted: a Slack
|
|
27
|
+
* `thread_ts`, a Telegram forum topic, unset on Discord (where a thread is
|
|
28
|
+
* its own channel).
|
|
29
|
+
*/
|
|
30
|
+
export type ConversationRef = {
|
|
31
|
+
readonly channel: ChannelId
|
|
32
|
+
readonly thread?: ThreadId
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type MessageRef = {
|
|
36
|
+
readonly conversation: ConversationRef
|
|
37
|
+
readonly id: MessageId
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Stable key for a conversation. Index per-conversation state with it. */
|
|
41
|
+
export const conversationKey = (ref: ConversationRef): string =>
|
|
42
|
+
ref.thread === undefined ? ref.channel : `${ref.channel}/${ref.thread}`
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Inbound
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What the world says to the agent. `raw` is the untouched platform payload,
|
|
50
|
+
* for everything the shared shape drops.
|
|
51
|
+
*
|
|
52
|
+
* `addressed` is the rule recipes branch on: a DM, a mention of the bot, or
|
|
53
|
+
* a reply to one of its messages. Each adapter owns that decision (and hides
|
|
54
|
+
* Telegram privacy mode / Discord intents behind it), and delivers `text`
|
|
55
|
+
* with the bot's own mention stripped.
|
|
56
|
+
*
|
|
57
|
+
* `Reaction.emoji` is the platform's own spelling, the same one `react`
|
|
58
|
+
* accepts: unicode where the platform speaks unicode, a shortcode where it
|
|
59
|
+
* speaks shortcodes.
|
|
60
|
+
*/
|
|
61
|
+
export type InboundEvent = Data.TaggedEnum<{
|
|
62
|
+
Message: {
|
|
63
|
+
readonly conversation: ConversationRef
|
|
64
|
+
readonly id: MessageId
|
|
65
|
+
readonly author: UserId
|
|
66
|
+
readonly text: string
|
|
67
|
+
readonly addressed: boolean
|
|
68
|
+
readonly replyTo?: MessageId
|
|
69
|
+
readonly raw: unknown
|
|
70
|
+
}
|
|
71
|
+
Reaction: {
|
|
72
|
+
readonly conversation: ConversationRef
|
|
73
|
+
readonly message: MessageId
|
|
74
|
+
readonly emoji: string
|
|
75
|
+
readonly author: UserId
|
|
76
|
+
readonly raw: unknown
|
|
77
|
+
}
|
|
78
|
+
Command: {
|
|
79
|
+
readonly conversation: ConversationRef
|
|
80
|
+
readonly name: string
|
|
81
|
+
readonly args: string
|
|
82
|
+
readonly author: UserId
|
|
83
|
+
readonly raw: unknown
|
|
84
|
+
}
|
|
85
|
+
Action: {
|
|
86
|
+
readonly conversation: ConversationRef
|
|
87
|
+
readonly actionId: string
|
|
88
|
+
readonly value?: string
|
|
89
|
+
readonly author: UserId
|
|
90
|
+
readonly raw: unknown
|
|
91
|
+
}
|
|
92
|
+
}>
|
|
93
|
+
|
|
94
|
+
export const InboundEvent = Data.taggedEnum<InboundEvent>()
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Outbound
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* What the agent says. `Text` is sent verbatim: each provider layer says
|
|
102
|
+
* which markup it expects (Telegram HTML, Slack markdown), and the prompt is
|
|
103
|
+
* where that gets decided. Nothing here converts. `Media` reuses the core
|
|
104
|
+
* {@link MediaSource}, so a URL, base64 or raw bytes all work; the adapter
|
|
105
|
+
* routes on `mimeType` (`image/*` to a photo endpoint, `audio/*` to an audio
|
|
106
|
+
* one, everything else to a file upload), which is why a URL source without
|
|
107
|
+
* a `mimeType` goes as a plain file. `Raw` bypasses the shape entirely and
|
|
108
|
+
* reaches the platform as-is: the escape hatch for buttons, cards and
|
|
109
|
+
* everything the verbs do not unify.
|
|
110
|
+
*/
|
|
111
|
+
export type OutboundBody = Data.TaggedEnum<{
|
|
112
|
+
Text: { readonly text: string }
|
|
113
|
+
Media: {
|
|
114
|
+
readonly media: MediaSource
|
|
115
|
+
readonly caption?: string
|
|
116
|
+
readonly filename?: string
|
|
117
|
+
}
|
|
118
|
+
Raw: { readonly payload: unknown }
|
|
119
|
+
}>
|
|
120
|
+
|
|
121
|
+
export const OutboundBody = Data.taggedEnum<OutboundBody>()
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A body plus the envelope fields true of any message. `replyTo` is ignored
|
|
125
|
+
* for a `Raw` body, which names its own wire fields.
|
|
126
|
+
*/
|
|
127
|
+
export type Outbound = {
|
|
128
|
+
readonly body: OutboundBody
|
|
129
|
+
readonly replyTo?: MessageId
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type OutboundOptions = { readonly replyTo?: MessageId }
|
|
133
|
+
|
|
134
|
+
const envelope = (body: OutboundBody, options?: OutboundOptions): Outbound => ({
|
|
135
|
+
body,
|
|
136
|
+
...(options?.replyTo !== undefined && { replyTo: options.replyTo }),
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
/** Text message. The overwhelmingly common case: `post(text("hi"))`. */
|
|
140
|
+
export const text = (body: string, options?: OutboundOptions): Outbound =>
|
|
141
|
+
envelope(OutboundBody.Text({ text: body }), options)
|
|
142
|
+
|
|
143
|
+
/** Media message. `caption` and `filename` describe the media, not the envelope. */
|
|
144
|
+
export const media = (
|
|
145
|
+
source: MediaSource,
|
|
146
|
+
options?: OutboundOptions & { readonly caption?: string; readonly filename?: string },
|
|
147
|
+
): Outbound =>
|
|
148
|
+
envelope(
|
|
149
|
+
OutboundBody.Media({
|
|
150
|
+
media: source,
|
|
151
|
+
...(options?.caption !== undefined && { caption: options.caption }),
|
|
152
|
+
...(options?.filename !== undefined && { filename: options.filename }),
|
|
153
|
+
}),
|
|
154
|
+
options,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
/** Platform payload, passed through untouched. */
|
|
158
|
+
export const raw = (payload: unknown): Outbound => ({ body: OutboundBody.Raw({ payload }) })
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Ambient conversation targeting
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The conversation the outbound verbs target. A tag with no default, so
|
|
166
|
+
* posting outside an established conversation is a compile error. Deep code
|
|
167
|
+
* (a tool posting progress, an approval resolver) inherits it rather than
|
|
168
|
+
* threading a ref down.
|
|
169
|
+
*/
|
|
170
|
+
export class CurrentConversation extends Context.Service<CurrentConversation, ConversationRef>()(
|
|
171
|
+
"@betalyra/effect-uai/Messenger/CurrentConversation",
|
|
172
|
+
) {}
|
|
173
|
+
|
|
174
|
+
/** Run `effect` against `ref`. Wrap a conversation fiber once; every nested post lands. */
|
|
175
|
+
export const inConversation =
|
|
176
|
+
(ref: ConversationRef) =>
|
|
177
|
+
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, Exclude<R, CurrentConversation>> =>
|
|
178
|
+
Effect.provideService(effect, CurrentConversation, ref)
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Service definition
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
/** Envelope of a streamed reply. `replyTo` lands on the first message it posts. */
|
|
185
|
+
export type StreamOptions = OutboundOptions
|
|
186
|
+
|
|
187
|
+
/** Platform ceilings, so recipes can see them before composing a message. */
|
|
188
|
+
export type MessengerLimits = {
|
|
189
|
+
/** Longest single text message. */
|
|
190
|
+
readonly maxText: number
|
|
191
|
+
/** Longest caption on a media message. */
|
|
192
|
+
readonly maxCaption: number
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Cross-platform messaging: one inbound stream, five outbound verbs.
|
|
197
|
+
*
|
|
198
|
+
* `events` is single-consumer and provider-owned. The connection opens when
|
|
199
|
+
* the layer is built and closes with its scope, so there is no connect /
|
|
200
|
+
* disconnect here, and reconnects, acks and callback answering all happen
|
|
201
|
+
* below this line.
|
|
202
|
+
*
|
|
203
|
+
* `post`, `typing` and `stream` target the ambient
|
|
204
|
+
* {@link CurrentConversation}; `edit` and `react` name a message outright.
|
|
205
|
+
*/
|
|
206
|
+
export type MessengerService = {
|
|
207
|
+
readonly events: Stream.Stream<InboundEvent, MessengerError.MessengerError>
|
|
208
|
+
|
|
209
|
+
/** Text past `limits.maxText` goes out as several messages; the id is the last. */
|
|
210
|
+
readonly post: (
|
|
211
|
+
msg: Outbound,
|
|
212
|
+
) => Effect.Effect<MessageId, MessengerError.MessengerError, CurrentConversation>
|
|
213
|
+
|
|
214
|
+
readonly edit: (
|
|
215
|
+
msg: MessageRef,
|
|
216
|
+
next: Outbound,
|
|
217
|
+
) => Effect.Effect<void, MessengerError.MessengerError>
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* `emoji` in the platform's own spelling, as its `Reaction` events deliver
|
|
221
|
+
* it. Nothing is translated; one the platform rejects fails
|
|
222
|
+
* `MessengerUnsupported`.
|
|
223
|
+
*/
|
|
224
|
+
readonly react: (
|
|
225
|
+
msg: MessageRef,
|
|
226
|
+
emoji: string,
|
|
227
|
+
) => Effect.Effect<void, MessengerError.MessengerError>
|
|
228
|
+
|
|
229
|
+
/** Activity indicator, kept alive by the adapter until the scope closes. */
|
|
230
|
+
readonly typing: Effect.Effect<
|
|
231
|
+
void,
|
|
232
|
+
MessengerError.MessengerError,
|
|
233
|
+
CurrentConversation | Scope.Scope
|
|
234
|
+
>
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Progressive delivery of a text stream; the mechanism is the adapter's.
|
|
238
|
+
* The id is the last message posted, `None` when the stream had no text.
|
|
239
|
+
*/
|
|
240
|
+
readonly stream: <E, R>(
|
|
241
|
+
deltas: Stream.Stream<string, E, R>,
|
|
242
|
+
options?: StreamOptions,
|
|
243
|
+
) => Effect.Effect<
|
|
244
|
+
Option.Option<MessageId>,
|
|
245
|
+
MessengerError.MessengerError | E,
|
|
246
|
+
R | CurrentConversation
|
|
247
|
+
>
|
|
248
|
+
|
|
249
|
+
readonly limits: MessengerLimits
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export class Messenger extends Context.Service<Messenger, MessengerService>()(
|
|
253
|
+
"@betalyra/effect-uai/Messenger",
|
|
254
|
+
) {}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Free helpers
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
export const events: Stream.Stream<InboundEvent, MessengerError.MessengerError, Messenger> =
|
|
261
|
+
Stream.unwrap(Effect.map(Messenger, (m) => m.events))
|
|
262
|
+
|
|
263
|
+
export const post = (
|
|
264
|
+
msg: Outbound,
|
|
265
|
+
): Effect.Effect<MessageId, MessengerError.MessengerError, Messenger | CurrentConversation> =>
|
|
266
|
+
Effect.flatMap(Messenger, (m) => m.post(msg))
|
|
267
|
+
|
|
268
|
+
export const edit = (
|
|
269
|
+
msg: MessageRef,
|
|
270
|
+
next: Outbound,
|
|
271
|
+
): Effect.Effect<void, MessengerError.MessengerError, Messenger> =>
|
|
272
|
+
Effect.flatMap(Messenger, (m) => m.edit(msg, next))
|
|
273
|
+
|
|
274
|
+
export const react = (
|
|
275
|
+
msg: MessageRef,
|
|
276
|
+
emoji: string,
|
|
277
|
+
): Effect.Effect<void, MessengerError.MessengerError, Messenger> =>
|
|
278
|
+
Effect.flatMap(Messenger, (m) => m.react(msg, emoji))
|
|
279
|
+
|
|
280
|
+
export const typing: Effect.Effect<
|
|
281
|
+
void,
|
|
282
|
+
MessengerError.MessengerError,
|
|
283
|
+
Messenger | CurrentConversation | Scope.Scope
|
|
284
|
+
> = Effect.flatMap(Messenger, (m) => m.typing)
|
|
285
|
+
|
|
286
|
+
export const stream = <E, R>(
|
|
287
|
+
deltas: Stream.Stream<string, E, R>,
|
|
288
|
+
options?: StreamOptions,
|
|
289
|
+
): Effect.Effect<
|
|
290
|
+
Option.Option<MessageId>,
|
|
291
|
+
MessengerError.MessengerError | E,
|
|
292
|
+
R | Messenger | CurrentConversation
|
|
293
|
+
> => Effect.flatMap(Messenger, (m) => m.stream(deltas, options))
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, it } from "@effect/vitest"
|
|
2
|
+
import { Array as Arr, Duration, Effect, Option, Result, Stream } from "effect"
|
|
3
|
+
import { TestClock } from "effect/testing"
|
|
4
|
+
import { expect } from "vitest"
|
|
5
|
+
import * as MockMessenger from "../testing/MockMessenger.js"
|
|
6
|
+
import {
|
|
7
|
+
ChannelId,
|
|
8
|
+
type ConversationRef,
|
|
9
|
+
MessageId,
|
|
10
|
+
type Outbound,
|
|
11
|
+
type StreamOptions,
|
|
12
|
+
inConversation,
|
|
13
|
+
} from "./Messenger.js"
|
|
14
|
+
import { type StreamViaEditsOptions, splitForLimit } from "./MessengerAdapter.js"
|
|
15
|
+
import { MessengerRateLimited } from "./MessengerError.js"
|
|
16
|
+
|
|
17
|
+
const here: ConversationRef = { channel: ChannelId("c1") }
|
|
18
|
+
|
|
19
|
+
const bodyOf = (msg: Outbound): string => (msg.body._tag === "Text" ? msg.body.text : "")
|
|
20
|
+
|
|
21
|
+
const posts = (calls: ReadonlyArray<MockMessenger.Call>) =>
|
|
22
|
+
Arr.filterMap(calls, (c) =>
|
|
23
|
+
c._tag === "Post" ? Result.succeed(bodyOf(c.message)) : Result.failVoid,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const edits = (calls: ReadonlyArray<MockMessenger.Call>) =>
|
|
27
|
+
Arr.filterMap(calls, (c) =>
|
|
28
|
+
c._tag === "Edit" ? Result.succeed(bodyOf(c.next)) : Result.failVoid,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
/** Run `deltas` through the mock's `stream` and hand back the call log. */
|
|
32
|
+
const streamed = (
|
|
33
|
+
deltas: ReadonlyArray<string>,
|
|
34
|
+
script: MockMessenger.MockMessengerScript = {},
|
|
35
|
+
streamOptions?: StreamOptions,
|
|
36
|
+
) =>
|
|
37
|
+
Effect.gen(function* () {
|
|
38
|
+
const { service, recorder } = MockMessenger.make(script)
|
|
39
|
+
const id = yield* service.stream(Stream.fromIterable(deltas), streamOptions)
|
|
40
|
+
const { calls } = yield* recorder
|
|
41
|
+
return { id, calls }
|
|
42
|
+
}).pipe(inConversation(here))
|
|
43
|
+
|
|
44
|
+
const options = (o: StreamViaEditsOptions): MockMessenger.MockMessengerScript => ({
|
|
45
|
+
streamOptions: o,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// splitForLimit
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
describe("splitForLimit", () => {
|
|
53
|
+
it("prefers a paragraph break, then a line, then a word", () => {
|
|
54
|
+
expect(splitForLimit("aaa\n\nbbb ccc", 8)).toEqual(["aaa", "bbb ccc"])
|
|
55
|
+
expect(splitForLimit("aaa\nbbbb ccc", 8)).toEqual(["aaa", "bbbb ccc"])
|
|
56
|
+
expect(splitForLimit("aaaa bbbb ccc", 9)).toEqual(["aaaa", "bbbb ccc"])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("hard-cuts a run with no boundary inside the limit", () => {
|
|
60
|
+
expect(splitForLimit("aaaaaaaaaa", 4)).toEqual(["aaaa", "aaaa", "aa"])
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// streamViaEdits
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
describe("streamViaEdits", () => {
|
|
69
|
+
// TestClock keeps wall time frozen, so the `every` gate never opens and
|
|
70
|
+
// every delta after the first coalesces into the final flush.
|
|
71
|
+
it.effect("coalesces deltas arriving inside the `every` window", () =>
|
|
72
|
+
Effect.gen(function* () {
|
|
73
|
+
const { calls } = yield* streamed(["Hel", "lo ", "the", "re!"])
|
|
74
|
+
|
|
75
|
+
expect(posts(calls)).toEqual(["Hel"])
|
|
76
|
+
expect(edits(calls)).toEqual(["Hello there!"])
|
|
77
|
+
}),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
it.effect("waits for `minChars` of growth before spending an edit", () =>
|
|
81
|
+
Effect.gen(function* () {
|
|
82
|
+
const { calls } = yield* streamed(
|
|
83
|
+
["aaaa", "bbbb", "cccc", "dddd", "eeee", "ffff"],
|
|
84
|
+
options({ every: 0, minChars: 10 }),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
// Edits land at 16 chars (12 of growth) and again on the final flush.
|
|
88
|
+
expect(posts(calls)).toEqual(["aaaa"])
|
|
89
|
+
expect(edits(calls).map((t) => t.length)).toEqual([16, 24])
|
|
90
|
+
}),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
it.effect("skips the final flush when the message already shows the text", () =>
|
|
94
|
+
Effect.gen(function* () {
|
|
95
|
+
const { calls } = yield* streamed(
|
|
96
|
+
["aaaaa", "bbbbb", "ccccc"],
|
|
97
|
+
options({ every: 0, minChars: 10 }),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
// The third delta opens the growth gate, so the tail is already sent.
|
|
101
|
+
expect(edits(calls)).toEqual(["aaaaabbbbbccccc"])
|
|
102
|
+
}),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
it.effect("rolls over to a new message past maxText", () =>
|
|
106
|
+
Effect.gen(function* () {
|
|
107
|
+
const { id, calls } = yield* streamed(["aaaa bbbb cccc dddd"], {
|
|
108
|
+
limits: { maxText: 10, maxCaption: 10 },
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// Cut on the word boundary, not mid-word, and the id is the last message.
|
|
112
|
+
expect(posts(calls)).toEqual(["aaaa bbbb", "cccc dddd"])
|
|
113
|
+
expect(edits(calls)).toEqual([])
|
|
114
|
+
expect(id).toEqual(Option.some(MessageId("m2")))
|
|
115
|
+
}),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
it.effect("quotes the question on the first message only, never on a rollover", () =>
|
|
119
|
+
Effect.gen(function* () {
|
|
120
|
+
const { calls } = yield* streamed(
|
|
121
|
+
["aaaa bbbb cccc dddd"],
|
|
122
|
+
{ limits: { maxText: 10, maxCaption: 10 } },
|
|
123
|
+
{ replyTo: MessageId("q1") },
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
const replies = Arr.filterMap(calls, (c) =>
|
|
127
|
+
c._tag === "Post" ? Result.succeed(c.message.replyTo) : Result.failVoid,
|
|
128
|
+
)
|
|
129
|
+
expect(replies).toEqual([MessageId("q1"), undefined])
|
|
130
|
+
}),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
it.effect("posts nothing for a stream with no text", () =>
|
|
134
|
+
Effect.gen(function* () {
|
|
135
|
+
const { id, calls } = yield* streamed(["", ""])
|
|
136
|
+
|
|
137
|
+
expect(calls).toEqual([])
|
|
138
|
+
expect(id).toEqual(Option.none())
|
|
139
|
+
}),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
// A model opening a tool-calling turn with a newline, which every platform
|
|
143
|
+
// rejects as an empty message.
|
|
144
|
+
it.effect("treats whitespace-only deltas as no text at all", () =>
|
|
145
|
+
Effect.gen(function* () {
|
|
146
|
+
const { id, calls } = yield* streamed(["\n", " ", "\n\n"])
|
|
147
|
+
|
|
148
|
+
expect(calls).toEqual([])
|
|
149
|
+
expect(id).toEqual(Option.none())
|
|
150
|
+
}),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
it.live("waits out a rate limit and re-sends the same edit", () =>
|
|
154
|
+
Effect.gen(function* () {
|
|
155
|
+
const limited = new MessengerRateLimited({
|
|
156
|
+
provider: "mock",
|
|
157
|
+
retryAfter: Duration.millis(10),
|
|
158
|
+
})
|
|
159
|
+
const { calls } = yield* streamed(["abc", "defghij"], {
|
|
160
|
+
...options({ every: 0, minChars: 1 }),
|
|
161
|
+
failures: [undefined, limited],
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
expect(posts(calls)).toEqual(["abc"])
|
|
165
|
+
expect(edits(calls)).toEqual(["abcdefghij", "abcdefghij"])
|
|
166
|
+
}),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
it.effect("gives up once the rate-limit budget is spent", () =>
|
|
170
|
+
Effect.gen(function* () {
|
|
171
|
+
const limited = new MessengerRateLimited({ provider: "mock", retryAfter: Duration.zero })
|
|
172
|
+
const failed = yield* Effect.flip(
|
|
173
|
+
streamed(["abc"], { ...options({ rateLimitRetries: 1 }), failures: [limited, limited] }),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
expect(failed._tag).toBe("MessengerRateLimited")
|
|
177
|
+
}).pipe(TestClock.withLive),
|
|
178
|
+
)
|
|
179
|
+
})
|