effect-assistant 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +22 -2
- package/dist/AppEvent.d.ts +111 -0
- package/dist/AppEvent.d.ts.map +1 -0
- package/dist/AppEvent.js +71 -0
- package/dist/AppEvent.js.map +1 -0
- package/dist/Assistant.d.ts +403 -0
- package/dist/Assistant.d.ts.map +1 -0
- package/dist/Assistant.js +126 -0
- package/dist/Assistant.js.map +1 -0
- package/dist/AssistantActivity.d.ts +85 -0
- package/dist/AssistantActivity.d.ts.map +1 -0
- package/dist/AssistantActivity.js +105 -0
- package/dist/AssistantActivity.js.map +1 -0
- package/dist/AssistantEngine.d.ts +116 -0
- package/dist/AssistantEngine.d.ts.map +1 -0
- package/dist/AssistantEngine.js +255 -0
- package/dist/AssistantEngine.js.map +1 -0
- package/dist/AssistantEvent.d.ts +551 -0
- package/dist/AssistantEvent.d.ts.map +1 -0
- package/dist/AssistantEvent.js +473 -0
- package/dist/AssistantEvent.js.map +1 -0
- package/dist/AssistantLoop.d.ts +173 -0
- package/dist/AssistantLoop.d.ts.map +1 -0
- package/dist/AssistantLoop.js +111 -0
- package/dist/AssistantLoop.js.map +1 -0
- package/dist/AssistantToolCall.d.ts +32 -0
- package/dist/AssistantToolCall.d.ts.map +1 -0
- package/dist/AssistantToolCall.js +37 -0
- package/dist/AssistantToolCall.js.map +1 -0
- package/dist/Block.d.ts +69 -0
- package/dist/Block.d.ts.map +1 -0
- package/dist/Block.js +39 -0
- package/dist/Block.js.map +1 -0
- package/dist/BlockKit.d.ts +180 -0
- package/dist/BlockKit.d.ts.map +1 -0
- package/dist/BlockKit.js +148 -0
- package/dist/BlockKit.js.map +1 -0
- package/dist/ChatId.d.ts +31 -0
- package/dist/ChatId.d.ts.map +1 -0
- package/dist/ChatId.js +36 -0
- package/dist/ChatId.js.map +1 -0
- package/dist/ChatLog.d.ts +109 -0
- package/dist/ChatLog.d.ts.map +1 -0
- package/dist/ChatLog.js +177 -0
- package/dist/ChatLog.js.map +1 -0
- package/dist/ChatView.d.ts +408 -0
- package/dist/ChatView.d.ts.map +1 -0
- package/dist/ChatView.js +443 -0
- package/dist/ChatView.js.map +1 -0
- package/dist/Chats.d.ts +110 -0
- package/dist/Chats.d.ts.map +1 -0
- package/dist/Chats.js +98 -0
- package/dist/Chats.js.map +1 -0
- package/dist/Compaction.d.ts +105 -0
- package/dist/Compaction.d.ts.map +1 -0
- package/dist/Compaction.js +126 -0
- package/dist/Compaction.js.map +1 -0
- package/dist/CurrentChat.d.ts +40 -0
- package/dist/CurrentChat.d.ts.map +1 -0
- package/dist/CurrentChat.js +33 -0
- package/dist/CurrentChat.js.map +1 -0
- package/dist/InputId.d.ts +38 -0
- package/dist/InputId.d.ts.map +1 -0
- package/dist/InputId.js +43 -0
- package/dist/InputId.js.map +1 -0
- package/dist/Instructions.d.ts +56 -0
- package/dist/Instructions.d.ts.map +1 -0
- package/dist/Instructions.js +50 -0
- package/dist/Instructions.js.map +1 -0
- package/dist/Memory.d.ts +190 -0
- package/dist/Memory.d.ts.map +1 -0
- package/dist/Memory.js +216 -0
- package/dist/Memory.js.map +1 -0
- package/dist/RunId.d.ts +41 -0
- package/dist/RunId.d.ts.map +1 -0
- package/dist/RunId.js +46 -0
- package/dist/RunId.js.map +1 -0
- package/dist/Skill.d.ts +72 -0
- package/dist/Skill.d.ts.map +1 -0
- package/dist/Skill.js +58 -0
- package/dist/Skill.js.map +1 -0
- package/dist/Specialist.d.ts +267 -0
- package/dist/Specialist.d.ts.map +1 -0
- package/dist/Specialist.js +119 -0
- package/dist/Specialist.js.map +1 -0
- package/dist/ToolDiscovery.d.ts +107 -0
- package/dist/ToolDiscovery.d.ts.map +1 -0
- package/dist/ToolDiscovery.js +140 -0
- package/dist/ToolDiscovery.js.map +1 -0
- package/dist/ToolFailure.d.ts +66 -0
- package/dist/ToolFailure.d.ts.map +1 -0
- package/dist/ToolFailure.js +79 -0
- package/dist/ToolFailure.js.map +1 -0
- package/dist/_virtual/_rolldown/runtime.js +13 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +24 -0
- package/dist/internal/chat.js +177 -0
- package/dist/internal/chat.js.map +1 -0
- package/dist/internal/events.d.ts +46 -0
- package/dist/internal/events.d.ts.map +1 -0
- package/dist/internal/events.js +65 -0
- package/dist/internal/events.js.map +1 -0
- package/dist/internal/history.js +147 -0
- package/dist/internal/history.js.map +1 -0
- package/dist/internal/instructions.js +11 -0
- package/dist/internal/instructions.js.map +1 -0
- package/dist/internal/loop.d.ts +73 -0
- package/dist/internal/loop.d.ts.map +1 -0
- package/dist/internal/loop.js +131 -0
- package/dist/internal/loop.js.map +1 -0
- package/dist/internal/schema.js +14 -0
- package/dist/internal/schema.js.map +1 -0
- package/dist/internal/skills.js +28 -0
- package/dist/internal/skills.js.map +1 -0
- package/dist/internal/specialists.js +272 -0
- package/dist/internal/specialists.js.map +1 -0
- package/dist/internal/telemetry.js +85 -0
- package/dist/internal/telemetry.js.map +1 -0
- package/dist/internal/toolkit.d.ts +62 -0
- package/dist/internal/toolkit.d.ts.map +1 -0
- package/dist/internal/toolkit.js +108 -0
- package/dist/internal/toolkit.js.map +1 -0
- package/dist/internal/tools.js +113 -0
- package/dist/internal/tools.js.map +1 -0
- package/dist/internal/turns.d.ts +64 -0
- package/dist/internal/turns.d.ts.map +1 -0
- package/dist/internal/turns.js +214 -0
- package/dist/internal/turns.js.map +1 -0
- package/dist/internal/uuid.js +45 -0
- package/dist/internal/uuid.js.map +1 -0
- package/dist/internal/writer.d.ts +12 -0
- package/dist/internal/writer.d.ts.map +1 -0
- package/package.json +52 -3
- package/src/AppEvent.ts +134 -0
- package/src/Assistant.ts +589 -0
- package/src/AssistantActivity.ts +150 -0
- package/src/AssistantEngine.ts +343 -0
- package/src/AssistantEvent.ts +569 -0
- package/src/AssistantLoop.ts +209 -0
- package/src/AssistantToolCall.ts +40 -0
- package/src/Block.ts +84 -0
- package/src/BlockKit.ts +298 -0
- package/src/ChatId.ts +36 -0
- package/src/ChatLog.ts +247 -0
- package/src/ChatView.ts +587 -0
- package/src/Chats.ts +122 -0
- package/src/Compaction.ts +207 -0
- package/src/CurrentChat.ts +47 -0
- package/src/InputId.ts +43 -0
- package/src/Instructions.ts +72 -0
- package/src/Memory.ts +276 -0
- package/src/RunId.ts +46 -0
- package/src/Skill.ts +91 -0
- package/src/Specialist.ts +355 -0
- package/src/ToolDiscovery.ts +179 -0
- package/src/ToolFailure.ts +74 -0
- package/src/index.ts +120 -0
- package/src/internal/chat.ts +323 -0
- package/src/internal/events.ts +100 -0
- package/src/internal/history.ts +294 -0
- package/src/internal/instructions.ts +9 -0
- package/src/internal/loop.ts +274 -0
- package/src/internal/schema.ts +25 -0
- package/src/internal/skills.ts +37 -0
- package/src/internal/specialists.ts +410 -0
- package/src/internal/telemetry.ts +125 -0
- package/src/internal/toolkit.ts +188 -0
- package/src/internal/tools.ts +159 -0
- package/src/internal/turns.ts +410 -0
- package/src/internal/uuid.ts +50 -0
- package/src/internal/writer.ts +12 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This module provides what a long chat needs so that the model can read a summary in place of
|
|
3
|
+
* everything before it.
|
|
4
|
+
*
|
|
5
|
+
* The app decides when to compact, which model writes the summary, and what the summary says.
|
|
6
|
+
* Its handler reads the chat's entries, has a model write a summary, and stores the summary as one
|
|
7
|
+
* of the assistant's own events (see {@link Summary}). Its loop then reads the chat through
|
|
8
|
+
* {@link since}. {@link inputTokens} helps decide when the prompt has grown large enough to
|
|
9
|
+
* compact. The log keeps every entry, so the user still sees the whole chat.
|
|
10
|
+
*
|
|
11
|
+
* @since 0.1.0
|
|
12
|
+
*/
|
|
13
|
+
import type * as Prompt from "effect/ai/Prompt"
|
|
14
|
+
import * as Option from "effect/Option"
|
|
15
|
+
import * as Predicate from "effect/Predicate"
|
|
16
|
+
import type * as Assistant from "./Assistant.ts"
|
|
17
|
+
import * as AssistantActivity from "./AssistantActivity.ts"
|
|
18
|
+
import * as AssistantEvent from "./AssistantEvent.ts"
|
|
19
|
+
import * as BlockKit from "./BlockKit.ts"
|
|
20
|
+
import { userText } from "./internal/history.ts"
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Represents the fields a summary event must have: its text and the last `seq` it covers.
|
|
24
|
+
*
|
|
25
|
+
* Entries after `throughSeq` are kept as they are, so an input that arrived while the summary was
|
|
26
|
+
* being written isn't lost.
|
|
27
|
+
*
|
|
28
|
+
* @category models
|
|
29
|
+
* @since 0.1.0
|
|
30
|
+
*/
|
|
31
|
+
export interface Summary {
|
|
32
|
+
readonly _tag: string
|
|
33
|
+
readonly text: string
|
|
34
|
+
readonly throughSeq: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Returns the number of input tokens used by the chat's last model call, which shows how large
|
|
39
|
+
* the prompt has grown. Returns 0 before the first call.
|
|
40
|
+
*
|
|
41
|
+
* @category utils
|
|
42
|
+
* @since 0.1.0
|
|
43
|
+
*/
|
|
44
|
+
export const inputTokens = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): number => {
|
|
45
|
+
const last = entries.findLast(
|
|
46
|
+
({ event }) =>
|
|
47
|
+
AssistantEvent.isLibraryEvent(event) && Predicate.isTagged(event, "ModelOutput") && event.usage !== undefined
|
|
48
|
+
)?.event
|
|
49
|
+
|
|
50
|
+
return last !== undefined && AssistantEvent.isLibraryEvent(last) && Predicate.isTagged(last, "ModelOutput")
|
|
51
|
+
? (last.usage?.inputTokens.total ?? 0)
|
|
52
|
+
: 0
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Returns the items that were open at `throughSeq` and are still open: blocks the user hasn't
|
|
57
|
+
* answered, and specialists that are working. The result is text for
|
|
58
|
+
* the model, or `None` when nothing is open.
|
|
59
|
+
*
|
|
60
|
+
* @category utils
|
|
61
|
+
* @since 0.1.0
|
|
62
|
+
*/
|
|
63
|
+
export const openItems = (
|
|
64
|
+
entries: ReadonlyArray<AssistantEvent.AnyEntry>,
|
|
65
|
+
throughSeq: number
|
|
66
|
+
): Option.Option<string> => {
|
|
67
|
+
const seqOf = (found: (event: AssistantEvent.AnyEntry["event"]) => boolean) =>
|
|
68
|
+
entries.find(({ event }) => found(event))?.seq ?? Number.POSITIVE_INFINITY
|
|
69
|
+
|
|
70
|
+
const opened = (seq: number) => seq <= throughSeq
|
|
71
|
+
|
|
72
|
+
const blocks = [...BlockKit.fromEntries(entries)].flatMap(([messageId, state]) => {
|
|
73
|
+
const shown = entries.find(
|
|
74
|
+
({ event }) =>
|
|
75
|
+
AssistantEvent.isLibraryEvent(event) && Predicate.isTagged(event, "AssistantMessage") && event.id === messageId
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const message = shown?.event
|
|
79
|
+
|
|
80
|
+
return state.open &&
|
|
81
|
+
shown !== undefined &&
|
|
82
|
+
opened(shown.seq) &&
|
|
83
|
+
message !== undefined &&
|
|
84
|
+
AssistantEvent.isLibraryEvent(message) &&
|
|
85
|
+
Predicate.isTagged(message, "AssistantMessage")
|
|
86
|
+
? [`- message ${messageId}, a ${message.block?.kind} block: ${message.text}`]
|
|
87
|
+
: []
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const specialists = [...AssistantActivity.fromEntries(entries).specialists.values()].flatMap((run) =>
|
|
91
|
+
opened(
|
|
92
|
+
seqOf(
|
|
93
|
+
(event) =>
|
|
94
|
+
AssistantEvent.isLibraryEvent(event) &&
|
|
95
|
+
Predicate.isTagged(event, "SpecialistStarted") &&
|
|
96
|
+
event.runId === run.runId
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
? [`- your ${run.specialist} specialist (${run.runId}), working on: ${run.goal}`]
|
|
100
|
+
: []
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const sections = [
|
|
104
|
+
blocks.length > 0 ? `Blocks the user hasn't answered:\n${blocks.join("\n")}` : "",
|
|
105
|
+
specialists.length > 0 ? `Specialists still working:\n${specialists.join("\n")}` : ""
|
|
106
|
+
].filter((section) => section.length > 0)
|
|
107
|
+
|
|
108
|
+
return sections.length === 0 ? Option.none() : Option.some(sections.join("\n\n"))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Returns the files the user attached up to `throughSeq`, as lines of text for the model, or
|
|
113
|
+
* `None` when there are none. A summary may leave out a file's id, which tools need.
|
|
114
|
+
*
|
|
115
|
+
* @category utils
|
|
116
|
+
* @since 0.1.0
|
|
117
|
+
*/
|
|
118
|
+
export const attachedFiles = (
|
|
119
|
+
entries: ReadonlyArray<AssistantEvent.AnyEntry>,
|
|
120
|
+
throughSeq: number
|
|
121
|
+
): Option.Option<string> => {
|
|
122
|
+
const lines = entries.flatMap(({ seq, event }) =>
|
|
123
|
+
seq <= throughSeq && AssistantEvent.isLibraryEvent(event) && Predicate.isTagged(event, "UserMessage")
|
|
124
|
+
? (event.attachments ?? []).map(AssistantEvent.attachmentLine)
|
|
125
|
+
: []
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return lines.length === 0 ? Option.none() : Option.some(lines.join("\n"))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Creates a history view that starts at the last summary.
|
|
133
|
+
*
|
|
134
|
+
* **Details**
|
|
135
|
+
*
|
|
136
|
+
* `isSummary` identifies the app's summary events. The model reads the last summary, the files
|
|
137
|
+
* attached earlier ({@link attachedFiles}), and the items that are still open
|
|
138
|
+
* ({@link openItems}), followed by the entries after the summary. When the chat has no summary,
|
|
139
|
+
* the view returns every entry.
|
|
140
|
+
*
|
|
141
|
+
* **Example** (Compacting a long chat after each turn)
|
|
142
|
+
*
|
|
143
|
+
* ```ts
|
|
144
|
+
* import { AppEvent, Assistant, type AssistantEvent, Compaction } from "effect-assistant"
|
|
145
|
+
* import * as LanguageModel from "effect/ai/LanguageModel"
|
|
146
|
+
* import * as Effect from "effect/Effect"
|
|
147
|
+
* import * as Schema from "effect/Schema"
|
|
148
|
+
* import * as Stream from "effect/Stream"
|
|
149
|
+
*
|
|
150
|
+
* class Summary extends Schema.TaggedClass<Summary>()("Summary", {
|
|
151
|
+
* text: Schema.String,
|
|
152
|
+
* throughSeq: Schema.Int
|
|
153
|
+
* }) {}
|
|
154
|
+
*
|
|
155
|
+
* declare const transcript: (entries: ReadonlyArray<AssistantEvent.AnyEntry>) => string
|
|
156
|
+
*
|
|
157
|
+
* export const MyAssistant = Assistant.make("my-assistant", { events: [AppEvent.make(Summary)] })
|
|
158
|
+
*
|
|
159
|
+
* const MyAssistantLive = MyAssistant.toLayer((turn) => {
|
|
160
|
+
* const compact = Effect.gen(function* () {
|
|
161
|
+
* const chat = yield* MyAssistant.Chat
|
|
162
|
+
* const entries = yield* chat.entries
|
|
163
|
+
*
|
|
164
|
+
* if (Compaction.inputTokens(entries) < 100_000) return
|
|
165
|
+
*
|
|
166
|
+
* const { text } = yield* LanguageModel.generateText({
|
|
167
|
+
* prompt: `Summarize this chat:\n\n${transcript(entries)}`
|
|
168
|
+
* })
|
|
169
|
+
*
|
|
170
|
+
* yield* chat.append(Summary.make({ text, throughSeq: entries.at(-1)?.seq ?? 0 }))
|
|
171
|
+
* })
|
|
172
|
+
*
|
|
173
|
+
* // After the reply, so the user doesn't wait for the summary.
|
|
174
|
+
* return Stream.concat(
|
|
175
|
+
* turn.loop({ instructions: "Be brief.", history: Compaction.since(Schema.is(Summary)) }),
|
|
176
|
+
* Stream.drain(Stream.fromEffect(compact))
|
|
177
|
+
* )
|
|
178
|
+
* })
|
|
179
|
+
* ```
|
|
180
|
+
*
|
|
181
|
+
* @category utils
|
|
182
|
+
* @since 0.1.0
|
|
183
|
+
*/
|
|
184
|
+
export const since =
|
|
185
|
+
(isSummary: (event: unknown) => event is Summary) =>
|
|
186
|
+
<Events extends { readonly _tag: string }>(
|
|
187
|
+
entries: ReadonlyArray<Assistant.Entry<Events>>
|
|
188
|
+
): ReadonlyArray<Assistant.Entry<Events> | Prompt.Message> => {
|
|
189
|
+
const last = entries.findLast(({ event }) => isSummary(event))?.event
|
|
190
|
+
|
|
191
|
+
if (last === undefined || !isSummary(last)) return entries
|
|
192
|
+
|
|
193
|
+
const open = Option.match(openItems(entries, last.throughSeq), {
|
|
194
|
+
onNone: () => "",
|
|
195
|
+
onSome: (items) => `\n\n<still_open>\n${items}\n</still_open>`
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const files = Option.match(attachedFiles(entries, last.throughSeq), {
|
|
199
|
+
onNone: () => "",
|
|
200
|
+
onSome: (lines) => `\n\n<files_attached_earlier>\n${lines}\n</files_attached_earlier>`
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
return [
|
|
204
|
+
userText(`<earlier_in_this_chat>\n${last.text}\n</earlier_in_this_chat>${files}${open}`),
|
|
205
|
+
...entries.filter(({ seq, event }) => seq > last.throughSeq && !isSummary(event))
|
|
206
|
+
]
|
|
207
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This module provides the `CurrentChat` service, which gives code that runs inside a chat
|
|
3
|
+
* access to that chat: its id, its entries, and a way to append to it.
|
|
4
|
+
*
|
|
5
|
+
* A turn's handler, its tools, and its specialists' runs get the chat with `yield* CurrentChat`.
|
|
6
|
+
* Inside a specialist's run, it is the chat the run belongs to, which is the chat of the turn
|
|
7
|
+
* that spawned the run. The service doesn't carry an assistant's event types, so code that
|
|
8
|
+
* doesn't know the assistant, such as a shared tool, can use it. Code that knows the assistant
|
|
9
|
+
* can use `MyAssistant.Chat`, which is the same chat typed with the assistant's events.
|
|
10
|
+
*
|
|
11
|
+
* @since 0.1.0
|
|
12
|
+
*/
|
|
13
|
+
import * as Context from "effect/Context"
|
|
14
|
+
import type * as Effect from "effect/Effect"
|
|
15
|
+
import type * as AssistantEvent from "./AssistantEvent.ts"
|
|
16
|
+
import type { ChatId } from "./ChatId.ts"
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Service that represents the chat the current code runs in.
|
|
20
|
+
*
|
|
21
|
+
* **Details**
|
|
22
|
+
*
|
|
23
|
+
* In a turn, it is the chat the turn runs in. In a specialist's run, and in its handler's build,
|
|
24
|
+
* it is the chat the run belongs to: the chat of the turn that spawned the run. A run's tools
|
|
25
|
+
* read and append to that chat.
|
|
26
|
+
*
|
|
27
|
+
* @category services
|
|
28
|
+
* @since 0.1.0
|
|
29
|
+
*/
|
|
30
|
+
export class CurrentChat extends Context.Service<
|
|
31
|
+
CurrentChat,
|
|
32
|
+
{
|
|
33
|
+
readonly chatId: ChatId
|
|
34
|
+
/**
|
|
35
|
+
* The chat's entries so far, in order. They include the library's events and the assistant's
|
|
36
|
+
* own events, decoded; check for these with `Schema.is(MyEvent)` or `instanceof`. An app event
|
|
37
|
+
* that no longer decodes is left out.
|
|
38
|
+
*/
|
|
39
|
+
readonly entries: Effect.Effect<ReadonlyArray<AssistantEvent.AnyEntry>>
|
|
40
|
+
/**
|
|
41
|
+
* Appends events after the chat's last entry and sends them to the chat's subscribers. The
|
|
42
|
+
* events can be the library's or the assistant's own (registered with its `events` option),
|
|
43
|
+
* such as a summary. An event that wakes a turn is handled by the next turn.
|
|
44
|
+
*/
|
|
45
|
+
readonly append: (...events: ReadonlyArray<{ readonly _tag: string }>) => Effect.Effect<void>
|
|
46
|
+
}
|
|
47
|
+
>()("effect-assistant/CurrentChat") {}
|
package/src/InputId.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This module provides the id of an input to an assistant. An input is a user message, a block
|
|
3
|
+
* answer, or one of the app's events, and the id is stored in its `id` field.
|
|
4
|
+
*
|
|
5
|
+
* @since 0.1.0
|
|
6
|
+
*/
|
|
7
|
+
import * as Effect from "effect/Effect"
|
|
8
|
+
import * as Schema from "effect/Schema"
|
|
9
|
+
import * as Uuid from "./internal/uuid.ts"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Schema for input ids. Any string is a valid input id, and the schema brands it.
|
|
13
|
+
*
|
|
14
|
+
* @category schemas
|
|
15
|
+
* @since 0.1.0
|
|
16
|
+
*/
|
|
17
|
+
export const InputId = Schema.String.pipe(Schema.brand("~effect-assistant/InputId"))
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Represents an input id.
|
|
21
|
+
*
|
|
22
|
+
* @category models
|
|
23
|
+
* @since 0.1.0
|
|
24
|
+
*/
|
|
25
|
+
export type InputId = typeof InputId.Type
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Creates a new random input id using Effect's `Random`. Seed `Random` in tests to get the same ids
|
|
29
|
+
* on every run.
|
|
30
|
+
*
|
|
31
|
+
* @category constructors
|
|
32
|
+
* @since 0.1.0
|
|
33
|
+
*/
|
|
34
|
+
export const make = (): Effect.Effect<InputId> => Effect.map(Uuid.make, (uuid) => InputId.make(uuid))
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Creates the input id for a key, such as `${runId}:order_placed`. The same key always gives the
|
|
38
|
+
* same id, so an input that is sent again is stored only once.
|
|
39
|
+
*
|
|
40
|
+
* @category constructors
|
|
41
|
+
* @since 0.1.0
|
|
42
|
+
*/
|
|
43
|
+
export const fromKey = (key: string): InputId => InputId.make(key)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defines the instructions that tell an assistant, specialist, or skill what to do.
|
|
3
|
+
*
|
|
4
|
+
* Instructions are text, or an Effect that produces text, optionally with span attributes
|
|
5
|
+
* (a {@link Resolved}). {@link resolve} turns them into a `Resolved`, and {@link protocol} is the
|
|
6
|
+
* text a turn's loop needs the model to know.
|
|
7
|
+
*
|
|
8
|
+
* @since 0.1.0
|
|
9
|
+
*/
|
|
10
|
+
import * as Effect from "effect/Effect"
|
|
11
|
+
import * as Predicate from "effect/Predicate"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The text the model has to know for a turn's loop to work.
|
|
15
|
+
*
|
|
16
|
+
* **Details**
|
|
17
|
+
*
|
|
18
|
+
* It tells the model that every step is a tool call, that `wait` (or a last message with
|
|
19
|
+
* `wait: true`) ends the turn, that the user sees only what it sends with `send_user_message`, and
|
|
20
|
+
* that messages can arrive mid-turn. The rest, such as what a `<specialist>` block or an app's
|
|
21
|
+
* event is, is in the tools' descriptions and in the history itself.
|
|
22
|
+
*
|
|
23
|
+
* `turn.instructions(text)` puts it before the text, and a custom loop on `AssistantLoop.run` can
|
|
24
|
+
* use it too.
|
|
25
|
+
*
|
|
26
|
+
* @category utils
|
|
27
|
+
* @since 0.1.0
|
|
28
|
+
*/
|
|
29
|
+
export const protocol = `Every step is a tool call. When you have nothing more to do right now, end your turn: set wait to true on your last send_user_message, or call wait if you have nothing to say.
|
|
30
|
+
The user sees only the text of what you send with send_user_message: anything else you write is never shown, so put the whole answer in the message, not next to it.
|
|
31
|
+
Messages and updates can arrive while you work: handle the newest one, and don't repeat what you already told the user.`
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Represents the span attributes added to every model call made with a prompt.
|
|
35
|
+
*
|
|
36
|
+
* @category models
|
|
37
|
+
* @since 0.1.0
|
|
38
|
+
*/
|
|
39
|
+
export type SpanAttributes = Readonly<Record<string, string | number | boolean>>
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Represents instructions resolved to text, with the span attributes to record them under.
|
|
43
|
+
*
|
|
44
|
+
* @category models
|
|
45
|
+
* @since 0.1.0
|
|
46
|
+
*/
|
|
47
|
+
export interface Resolved {
|
|
48
|
+
readonly text: string
|
|
49
|
+
readonly attributes?: SpanAttributes | undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Represents instructions as text, or as an Effect that produces the text (optionally with span
|
|
54
|
+
* attributes).
|
|
55
|
+
*
|
|
56
|
+
* @category models
|
|
57
|
+
* @since 0.1.0
|
|
58
|
+
*/
|
|
59
|
+
export type Instructions<R = never> = string | Effect.Effect<string | Resolved, never, R>
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolves instructions to their text and span attributes.
|
|
63
|
+
*
|
|
64
|
+
* @category utils
|
|
65
|
+
* @since 0.1.0
|
|
66
|
+
*/
|
|
67
|
+
export const resolve = Effect.fn("Instructions.resolve")(function* <R = never>(instructions: Instructions<R>) {
|
|
68
|
+
return Predicate.isString(instructions) ? { text: instructions } : toResolved(yield* instructions)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const toResolved = (resolved: string | Resolved): Resolved =>
|
|
72
|
+
Predicate.isString(resolved) ? { text: resolved } : resolved
|
package/src/Memory.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps facts an assistant remembers across chats.
|
|
3
|
+
*
|
|
4
|
+
* The model keeps facts with the `remember` tool, finds them with `recall`, and drops them with
|
|
5
|
+
* `forget` ({@link Tools}). Where facts are stored and who shares them is up to the application,
|
|
6
|
+
* which implements {@link MemoryStore}; every method receives the chat it's for. {@link render}
|
|
7
|
+
* shows the facts in a turn's instructions.
|
|
8
|
+
*
|
|
9
|
+
* @since 0.1.0
|
|
10
|
+
*/
|
|
11
|
+
import * as Tool from "effect/ai/Tool"
|
|
12
|
+
import * as Toolkit from "effect/ai/Toolkit"
|
|
13
|
+
import * as Clock from "effect/Clock"
|
|
14
|
+
import * as Context from "effect/Context"
|
|
15
|
+
import * as DateTime from "effect/DateTime"
|
|
16
|
+
import * as Effect from "effect/Effect"
|
|
17
|
+
import * as Layer from "effect/Layer"
|
|
18
|
+
import * as Option from "effect/Option"
|
|
19
|
+
import * as Ref from "effect/Ref"
|
|
20
|
+
import * as Schema from "effect/Schema"
|
|
21
|
+
import type { ChatId } from "./ChatId.ts"
|
|
22
|
+
import { CurrentChat } from "./CurrentChat.ts"
|
|
23
|
+
import * as Uuid from "./internal/uuid.ts"
|
|
24
|
+
import * as ToolFailure from "./ToolFailure.ts"
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Represents a fact the assistant remembers.
|
|
28
|
+
*
|
|
29
|
+
* It holds the fact's id, one short sentence, and when the fact stops mattering, if it does.
|
|
30
|
+
*
|
|
31
|
+
* @category schemas
|
|
32
|
+
* @since 0.1.0
|
|
33
|
+
*/
|
|
34
|
+
export class Fact extends Schema.Class<Fact>("effect-assistant/Memory/Fact")({
|
|
35
|
+
id: Schema.String,
|
|
36
|
+
text: Schema.String,
|
|
37
|
+
expiresAt: Schema.optional(Schema.DateTimeUtc)
|
|
38
|
+
}) {}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Represents a fact to keep, as the `remember` tool hands it to the store.
|
|
42
|
+
*
|
|
43
|
+
* @category models
|
|
44
|
+
* @since 0.1.0
|
|
45
|
+
*/
|
|
46
|
+
export interface NewFact {
|
|
47
|
+
readonly text: string
|
|
48
|
+
readonly expiresAt?: DateTime.Utc | undefined
|
|
49
|
+
/** The id of a fact this one replaces. */
|
|
50
|
+
readonly replaces?: string | undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Service that keeps facts, implemented by the application.
|
|
55
|
+
*
|
|
56
|
+
* Every method receives the chat it's for, and the application decides whose facts that means
|
|
57
|
+
* (the chat's user, their team, ...).
|
|
58
|
+
*
|
|
59
|
+
* @category services
|
|
60
|
+
* @since 0.1.0
|
|
61
|
+
*/
|
|
62
|
+
export class MemoryStore extends Context.Service<
|
|
63
|
+
MemoryStore,
|
|
64
|
+
{
|
|
65
|
+
/** Returns the unexpired facts a chat should know. */
|
|
66
|
+
readonly facts: (chatId: ChatId) => Effect.Effect<ReadonlyArray<Fact>>
|
|
67
|
+
/** Keeps a fact for the chat's owner, and returns its id. */
|
|
68
|
+
readonly remember: (chatId: ChatId, fact: NewFact) => Effect.Effect<string>
|
|
69
|
+
/**
|
|
70
|
+
* Drops a fact. Fails with a message for the model when the chat's owner has no fact with
|
|
71
|
+
* that id.
|
|
72
|
+
*/
|
|
73
|
+
readonly forget: (chatId: ChatId, id: string) => Effect.Effect<void, ToolFailure.ToolFailure>
|
|
74
|
+
/** Returns the chat owner's facts that match a query, best first. */
|
|
75
|
+
readonly recall: (chatId: ChatId, query: string) => Effect.Effect<ReadonlyArray<Fact>>
|
|
76
|
+
}
|
|
77
|
+
>()("effect-assistant/Memory/MemoryStore") {}
|
|
78
|
+
|
|
79
|
+
const day = (at: DateTime.Utc): string => DateTime.formatIsoDateUtc(at)
|
|
80
|
+
|
|
81
|
+
const line = (fact: Fact): string =>
|
|
82
|
+
`- [${fact.id}] ${fact.text}${fact.expiresAt === undefined ? "" : ` (until ${day(fact.expiresAt)})`}`
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Renders facts as the model reads them, for a turn's instructions.
|
|
86
|
+
*
|
|
87
|
+
* Each fact is listed with its id, so the model can replace or forget it. The result is an empty
|
|
88
|
+
* string when there are no facts.
|
|
89
|
+
*
|
|
90
|
+
* @see {@link Tools} for an example
|
|
91
|
+
*
|
|
92
|
+
* @category utils
|
|
93
|
+
* @since 0.1.0
|
|
94
|
+
*/
|
|
95
|
+
export const render = (facts: ReadonlyArray<Fact>): string =>
|
|
96
|
+
facts.length === 0
|
|
97
|
+
? ""
|
|
98
|
+
: `<memory>\nWhat you remember from earlier chats. Use it without saying where it came from; when their data or what they say now disagrees, that wins.\n${facts.map(line).join("\n")}\n</memory>`
|
|
99
|
+
|
|
100
|
+
const words = (text: string): ReadonlyArray<string> =>
|
|
101
|
+
text
|
|
102
|
+
.toLowerCase()
|
|
103
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
104
|
+
.filter((word) => word.length > 2)
|
|
105
|
+
|
|
106
|
+
const matches = (fact: Fact, query: ReadonlyArray<string>): number => {
|
|
107
|
+
const own = words(fact.text)
|
|
108
|
+
|
|
109
|
+
return query.filter((word) => own.some((w) => w.startsWith(word) || word.startsWith(w))).length
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const live = (now: number) => (fact: Fact) =>
|
|
113
|
+
fact.expiresAt === undefined || DateTime.toEpochMillis(fact.expiresAt) > now
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Layer that provides a {@link MemoryStore} in memory, for tests and local runs.
|
|
117
|
+
*
|
|
118
|
+
* Every chat shares one set of facts.
|
|
119
|
+
*
|
|
120
|
+
* @category layers
|
|
121
|
+
* @since 0.1.0
|
|
122
|
+
*/
|
|
123
|
+
export const layerMemory: Layer.Layer<MemoryStore> = Layer.effect(
|
|
124
|
+
MemoryStore,
|
|
125
|
+
Effect.gen(function* () {
|
|
126
|
+
// Newest first.
|
|
127
|
+
const all = yield* Ref.make<ReadonlyArray<Fact>>([])
|
|
128
|
+
|
|
129
|
+
const current = Effect.gen(function* () {
|
|
130
|
+
const now = yield* Clock.currentTimeMillis
|
|
131
|
+
|
|
132
|
+
return (yield* Ref.get(all)).filter(live(now))
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
return MemoryStore.of({
|
|
136
|
+
facts: () => current,
|
|
137
|
+
remember: Effect.fnUntraced(function* (_chatId: ChatId, fact: NewFact) {
|
|
138
|
+
const same = (yield* current).find((kept) => kept.text === fact.text.trim())
|
|
139
|
+
const id = same?.id ?? `f-${(yield* Uuid.make).slice(0, 8)}`
|
|
140
|
+
const kept = new Fact({ id, text: fact.text.trim(), expiresAt: fact.expiresAt })
|
|
141
|
+
|
|
142
|
+
yield* Ref.update(all, (facts) => [kept, ...facts.filter((old) => old.id !== id && old.id !== fact.replaces)])
|
|
143
|
+
|
|
144
|
+
return id
|
|
145
|
+
}),
|
|
146
|
+
forget: Effect.fnUntraced(function* (_chatId: ChatId, id: string) {
|
|
147
|
+
if (!(yield* current).some((fact) => fact.id === id))
|
|
148
|
+
return yield* ToolFailure.fail(`Nothing remembered as ${id}.`)
|
|
149
|
+
|
|
150
|
+
yield* Ref.update(all, (facts) => facts.filter((fact) => fact.id !== id))
|
|
151
|
+
}),
|
|
152
|
+
recall: Effect.fnUntraced(function* (_chatId: ChatId, query: string) {
|
|
153
|
+
const wanted = words(query)
|
|
154
|
+
|
|
155
|
+
return (yield* current)
|
|
156
|
+
.map((fact) => ({ fact, score: matches(fact, wanted) }))
|
|
157
|
+
.filter(({ score }) => score > 0)
|
|
158
|
+
.sort((a, b) => b.score - a.score)
|
|
159
|
+
.slice(0, 10)
|
|
160
|
+
.map(({ fact }) => fact)
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
const Remember = Tool.make("remember", {
|
|
167
|
+
description: `Remember something for later chats. Only what you can't look up again and that will still matter: a preference, a standing decision, how they like to work ("Rotterdam's copy is always Dutch"). For something short-lived ("out of office until Friday"), pass until. Not records your tools can read (orders, locations, budgets, people), not the state of a task in progress, and nothing you'd only need in this chat. One fact per call, in one short sentence that stands on its own. When it changes one you already have, pass that one's id as replaces.`,
|
|
168
|
+
parameters: Schema.Struct({
|
|
169
|
+
text: Schema.String,
|
|
170
|
+
until: Schema.optional(Schema.String).annotate({
|
|
171
|
+
description: "For something short-lived: the last day it matters, as YYYY-MM-DD."
|
|
172
|
+
}),
|
|
173
|
+
replaces: Schema.optional(Schema.String).annotate({ description: "The id of a fact this one replaces." })
|
|
174
|
+
}),
|
|
175
|
+
success: Schema.String,
|
|
176
|
+
failure: ToolFailure.ToolFailure,
|
|
177
|
+
dependencies: [CurrentChat, MemoryStore]
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const Forget = Tool.make("forget", {
|
|
181
|
+
description: "Forget a fact you remember, by its id, when they ask or it's no longer true.",
|
|
182
|
+
parameters: Schema.Struct({ id: Schema.String }),
|
|
183
|
+
success: Schema.String,
|
|
184
|
+
failure: ToolFailure.ToolFailure,
|
|
185
|
+
dependencies: [CurrentChat, MemoryStore]
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
const Recall = Tool.make("recall", {
|
|
189
|
+
description: "Search what you remember, by words that would be in it.",
|
|
190
|
+
parameters: Schema.Struct({ query: Schema.String }),
|
|
191
|
+
success: Schema.Array(Schema.String),
|
|
192
|
+
failure: ToolFailure.ToolFailure,
|
|
193
|
+
dependencies: [CurrentChat, MemoryStore]
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Toolkit with the `remember`, `forget`, and `recall` tools.
|
|
198
|
+
*
|
|
199
|
+
* **Details**
|
|
200
|
+
*
|
|
201
|
+
* Merge it into the loop's toolkit, and provide {@link layerTools} and a {@link MemoryStore}. Show
|
|
202
|
+
* the facts in the turn's instructions with {@link render}.
|
|
203
|
+
*
|
|
204
|
+
* **Example** (Giving an assistant memory)
|
|
205
|
+
*
|
|
206
|
+
* ```ts
|
|
207
|
+
* import { Assistant, Memory } from "effect-assistant"
|
|
208
|
+
* import * as Effect from "effect/Effect"
|
|
209
|
+
* import * as Layer from "effect/Layer"
|
|
210
|
+
* import * as Stream from "effect/Stream"
|
|
211
|
+
*
|
|
212
|
+
* export const MyAssistant = Assistant.make("my-assistant")
|
|
213
|
+
*
|
|
214
|
+
* const MyAssistantLive = MyAssistant.toLayer(
|
|
215
|
+
* Effect.gen(function* () {
|
|
216
|
+
* const memory = yield* Memory.MemoryStore
|
|
217
|
+
*
|
|
218
|
+
* return (turn) =>
|
|
219
|
+
* Stream.unwrap(
|
|
220
|
+
* Effect.map(memory.facts(turn.chatId), (facts) =>
|
|
221
|
+
* turn.loop({
|
|
222
|
+
* instructions: `You help a small team.\n\n${Memory.render(facts)}`,
|
|
223
|
+
* toolkit: Memory.Tools
|
|
224
|
+
* })
|
|
225
|
+
* )
|
|
226
|
+
* ).pipe(Stream.provideService(Memory.MemoryStore, memory))
|
|
227
|
+
* })
|
|
228
|
+
* ).pipe(Layer.provide(Memory.layerTools), Layer.provide(Memory.layerMemory))
|
|
229
|
+
* ```
|
|
230
|
+
*
|
|
231
|
+
* @category tools
|
|
232
|
+
* @since 0.1.0
|
|
233
|
+
*/
|
|
234
|
+
export const Tools = Toolkit.make(Remember, Forget, Recall)
|
|
235
|
+
|
|
236
|
+
const endOf = (date: string): Option.Option<DateTime.Utc> =>
|
|
237
|
+
/^\d{4}-\d{2}-\d{2}$/.test(date) ? DateTime.make(`${date}T23:59:59Z`) : Option.none()
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Layer that provides the handlers of {@link Tools}.
|
|
241
|
+
*
|
|
242
|
+
* The handlers call the {@link MemoryStore} with the chat they run in.
|
|
243
|
+
*
|
|
244
|
+
* @see {@link Tools} for an example
|
|
245
|
+
*
|
|
246
|
+
* @category layers
|
|
247
|
+
* @since 0.1.0
|
|
248
|
+
*/
|
|
249
|
+
export const layerTools = Tools.toLayer({
|
|
250
|
+
remember: Effect.fnUntraced(function* ({ text, until, replaces }) {
|
|
251
|
+
const chat = yield* CurrentChat
|
|
252
|
+
const store = yield* MemoryStore
|
|
253
|
+
const expiresAt = until === undefined ? Option.none() : endOf(until)
|
|
254
|
+
|
|
255
|
+
if (until !== undefined && Option.isNone(expiresAt)) {
|
|
256
|
+
return yield* ToolFailure.fail(`until must be a date as YYYY-MM-DD, not ${until}.`)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const id = yield* store.remember(chat.chatId, { text, expiresAt: Option.getOrUndefined(expiresAt), replaces })
|
|
260
|
+
|
|
261
|
+
return `Remembered as ${id}.`
|
|
262
|
+
}),
|
|
263
|
+
forget: Effect.fnUntraced(function* ({ id }) {
|
|
264
|
+
const chat = yield* CurrentChat
|
|
265
|
+
|
|
266
|
+
yield* (yield* MemoryStore).forget(chat.chatId, id)
|
|
267
|
+
|
|
268
|
+
return `Forgot ${id}.`
|
|
269
|
+
}),
|
|
270
|
+
recall: Effect.fnUntraced(function* ({ query }) {
|
|
271
|
+
const chat = yield* CurrentChat
|
|
272
|
+
const found = yield* (yield* MemoryStore).recall(chat.chatId, query)
|
|
273
|
+
|
|
274
|
+
return found.map(line)
|
|
275
|
+
})
|
|
276
|
+
})
|
package/src/RunId.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This module provides the id of a background run, such as a specialist run or a workflow started
|
|
3
|
+
* from a tool. A run id is `run:` followed by a UUID.
|
|
4
|
+
*
|
|
5
|
+
* @since 0.1.0
|
|
6
|
+
*/
|
|
7
|
+
import * as Effect from "effect/Effect"
|
|
8
|
+
import * as Schema from "effect/Schema"
|
|
9
|
+
import * as Uuid from "./internal/uuid.ts"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Schema for run ids of the form `` `run:${uuid}` ``, branded.
|
|
13
|
+
*
|
|
14
|
+
* @category schemas
|
|
15
|
+
* @since 0.1.0
|
|
16
|
+
*/
|
|
17
|
+
export const RunId = Schema.TemplateLiteral(["run:", Uuid.Uuid]).pipe(Schema.brand("~effect-assistant/RunId"))
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Represents a run id.
|
|
21
|
+
*
|
|
22
|
+
* @category models
|
|
23
|
+
* @since 0.1.0
|
|
24
|
+
*/
|
|
25
|
+
export type RunId = typeof RunId.Type
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Creates a new random run id using Effect's `Random`.
|
|
29
|
+
*
|
|
30
|
+
* @category constructors
|
|
31
|
+
* @since 0.1.0
|
|
32
|
+
*/
|
|
33
|
+
export const make = (): Effect.Effect<RunId> => Effect.map(Uuid.make, (uuid) => RunId.make(`run:${uuid}`))
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Creates the run id for a key. The same key always gives the same id.
|
|
37
|
+
*
|
|
38
|
+
* **When to use**
|
|
39
|
+
*
|
|
40
|
+
* Use it for a run that a tool call starts, with the call's `AssistantToolCall.idempotencyKey` as
|
|
41
|
+
* the key, so running the call again finds the same run.
|
|
42
|
+
*
|
|
43
|
+
* @category constructors
|
|
44
|
+
* @since 0.1.0
|
|
45
|
+
*/
|
|
46
|
+
export const fromKey = (key: string): RunId => RunId.make(`run:${Uuid.fromKey(key)}`)
|