effect-assistant 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -2
  3. package/dist/AppEvent.d.ts +111 -0
  4. package/dist/AppEvent.d.ts.map +1 -0
  5. package/dist/AppEvent.js +71 -0
  6. package/dist/AppEvent.js.map +1 -0
  7. package/dist/Assistant.d.ts +403 -0
  8. package/dist/Assistant.d.ts.map +1 -0
  9. package/dist/Assistant.js +126 -0
  10. package/dist/Assistant.js.map +1 -0
  11. package/dist/AssistantActivity.d.ts +85 -0
  12. package/dist/AssistantActivity.d.ts.map +1 -0
  13. package/dist/AssistantActivity.js +105 -0
  14. package/dist/AssistantActivity.js.map +1 -0
  15. package/dist/AssistantEngine.d.ts +116 -0
  16. package/dist/AssistantEngine.d.ts.map +1 -0
  17. package/dist/AssistantEngine.js +255 -0
  18. package/dist/AssistantEngine.js.map +1 -0
  19. package/dist/AssistantEvent.d.ts +614 -0
  20. package/dist/AssistantEvent.d.ts.map +1 -0
  21. package/dist/AssistantEvent.js +523 -0
  22. package/dist/AssistantEvent.js.map +1 -0
  23. package/dist/AssistantLoop.d.ts +171 -0
  24. package/dist/AssistantLoop.d.ts.map +1 -0
  25. package/dist/AssistantLoop.js +108 -0
  26. package/dist/AssistantLoop.js.map +1 -0
  27. package/dist/AssistantToolCall.d.ts +32 -0
  28. package/dist/AssistantToolCall.d.ts.map +1 -0
  29. package/dist/AssistantToolCall.js +37 -0
  30. package/dist/AssistantToolCall.js.map +1 -0
  31. package/dist/Block.d.ts +69 -0
  32. package/dist/Block.d.ts.map +1 -0
  33. package/dist/Block.js +39 -0
  34. package/dist/Block.js.map +1 -0
  35. package/dist/BlockKit.d.ts +184 -0
  36. package/dist/BlockKit.d.ts.map +1 -0
  37. package/dist/BlockKit.js +151 -0
  38. package/dist/BlockKit.js.map +1 -0
  39. package/dist/ChatId.d.ts +31 -0
  40. package/dist/ChatId.d.ts.map +1 -0
  41. package/dist/ChatId.js +36 -0
  42. package/dist/ChatId.js.map +1 -0
  43. package/dist/ChatLog.d.ts +109 -0
  44. package/dist/ChatLog.d.ts.map +1 -0
  45. package/dist/ChatLog.js +177 -0
  46. package/dist/ChatLog.js.map +1 -0
  47. package/dist/ChatView.d.ts +459 -0
  48. package/dist/ChatView.d.ts.map +1 -0
  49. package/dist/ChatView.js +496 -0
  50. package/dist/ChatView.js.map +1 -0
  51. package/dist/Chats.d.ts +110 -0
  52. package/dist/Chats.d.ts.map +1 -0
  53. package/dist/Chats.js +98 -0
  54. package/dist/Chats.js.map +1 -0
  55. package/dist/Compaction.d.ts +105 -0
  56. package/dist/Compaction.d.ts.map +1 -0
  57. package/dist/Compaction.js +133 -0
  58. package/dist/Compaction.js.map +1 -0
  59. package/dist/CurrentChat.d.ts +40 -0
  60. package/dist/CurrentChat.d.ts.map +1 -0
  61. package/dist/CurrentChat.js +33 -0
  62. package/dist/CurrentChat.js.map +1 -0
  63. package/dist/Decision.d.ts +145 -0
  64. package/dist/Decision.d.ts.map +1 -0
  65. package/dist/Decision.js +178 -0
  66. package/dist/Decision.js.map +1 -0
  67. package/dist/DecisionId.d.ts +37 -0
  68. package/dist/DecisionId.d.ts.map +1 -0
  69. package/dist/DecisionId.js +44 -0
  70. package/dist/DecisionId.js.map +1 -0
  71. package/dist/InputId.d.ts +38 -0
  72. package/dist/InputId.d.ts.map +1 -0
  73. package/dist/InputId.js +43 -0
  74. package/dist/InputId.js.map +1 -0
  75. package/dist/Instructions.d.ts +56 -0
  76. package/dist/Instructions.d.ts.map +1 -0
  77. package/dist/Instructions.js +50 -0
  78. package/dist/Instructions.js.map +1 -0
  79. package/dist/Memory.d.ts +190 -0
  80. package/dist/Memory.d.ts.map +1 -0
  81. package/dist/Memory.js +216 -0
  82. package/dist/Memory.js.map +1 -0
  83. package/dist/RunId.d.ts +41 -0
  84. package/dist/RunId.d.ts.map +1 -0
  85. package/dist/RunId.js +46 -0
  86. package/dist/RunId.js.map +1 -0
  87. package/dist/Skill.d.ts +72 -0
  88. package/dist/Skill.d.ts.map +1 -0
  89. package/dist/Skill.js +58 -0
  90. package/dist/Skill.js.map +1 -0
  91. package/dist/Specialist.d.ts +267 -0
  92. package/dist/Specialist.d.ts.map +1 -0
  93. package/dist/Specialist.js +119 -0
  94. package/dist/Specialist.js.map +1 -0
  95. package/dist/ToolDiscovery.d.ts +108 -0
  96. package/dist/ToolDiscovery.d.ts.map +1 -0
  97. package/dist/ToolDiscovery.js +139 -0
  98. package/dist/ToolDiscovery.js.map +1 -0
  99. package/dist/ToolFailure.d.ts +66 -0
  100. package/dist/ToolFailure.d.ts.map +1 -0
  101. package/dist/ToolFailure.js +79 -0
  102. package/dist/ToolFailure.js.map +1 -0
  103. package/dist/_virtual/_rolldown/runtime.js +13 -0
  104. package/dist/index.d.ts +26 -0
  105. package/dist/index.js +26 -0
  106. package/dist/internal/chat.js +202 -0
  107. package/dist/internal/chat.js.map +1 -0
  108. package/dist/internal/decisions.js +66 -0
  109. package/dist/internal/decisions.js.map +1 -0
  110. package/dist/internal/events.d.ts +46 -0
  111. package/dist/internal/events.d.ts.map +1 -0
  112. package/dist/internal/events.js +65 -0
  113. package/dist/internal/events.js.map +1 -0
  114. package/dist/internal/history.js +185 -0
  115. package/dist/internal/history.js.map +1 -0
  116. package/dist/internal/instructions.js +11 -0
  117. package/dist/internal/instructions.js.map +1 -0
  118. package/dist/internal/loop.d.ts +73 -0
  119. package/dist/internal/loop.d.ts.map +1 -0
  120. package/dist/internal/loop.js +166 -0
  121. package/dist/internal/loop.js.map +1 -0
  122. package/dist/internal/schema.js +14 -0
  123. package/dist/internal/schema.js.map +1 -0
  124. package/dist/internal/skills.js +28 -0
  125. package/dist/internal/skills.js.map +1 -0
  126. package/dist/internal/specialists.js +273 -0
  127. package/dist/internal/specialists.js.map +1 -0
  128. package/dist/internal/telemetry.js +85 -0
  129. package/dist/internal/telemetry.js.map +1 -0
  130. package/dist/internal/toolkit.d.ts +76 -0
  131. package/dist/internal/toolkit.d.ts.map +1 -0
  132. package/dist/internal/toolkit.js +129 -0
  133. package/dist/internal/toolkit.js.map +1 -0
  134. package/dist/internal/tools.js +132 -0
  135. package/dist/internal/tools.js.map +1 -0
  136. package/dist/internal/turns.d.ts +64 -0
  137. package/dist/internal/turns.d.ts.map +1 -0
  138. package/dist/internal/turns.js +221 -0
  139. package/dist/internal/turns.js.map +1 -0
  140. package/dist/internal/uuid.js +45 -0
  141. package/dist/internal/uuid.js.map +1 -0
  142. package/dist/internal/writer.d.ts +12 -0
  143. package/dist/internal/writer.d.ts.map +1 -0
  144. package/package.json +51 -3
  145. package/src/AppEvent.ts +134 -0
  146. package/src/Assistant.ts +589 -0
  147. package/src/AssistantActivity.ts +150 -0
  148. package/src/AssistantEngine.ts +343 -0
  149. package/src/AssistantEvent.ts +635 -0
  150. package/src/AssistantLoop.ts +207 -0
  151. package/src/AssistantToolCall.ts +40 -0
  152. package/src/Block.ts +84 -0
  153. package/src/BlockKit.ts +311 -0
  154. package/src/ChatId.ts +36 -0
  155. package/src/ChatLog.ts +247 -0
  156. package/src/ChatView.ts +658 -0
  157. package/src/Chats.ts +122 -0
  158. package/src/Compaction.ts +224 -0
  159. package/src/CurrentChat.ts +47 -0
  160. package/src/Decision.ts +241 -0
  161. package/src/DecisionId.ts +46 -0
  162. package/src/InputId.ts +43 -0
  163. package/src/Instructions.ts +72 -0
  164. package/src/Memory.ts +276 -0
  165. package/src/RunId.ts +46 -0
  166. package/src/Skill.ts +91 -0
  167. package/src/Specialist.ts +355 -0
  168. package/src/ToolDiscovery.ts +187 -0
  169. package/src/ToolFailure.ts +74 -0
  170. package/src/index.ts +130 -0
  171. package/src/internal/chat.ts +372 -0
  172. package/src/internal/decisions.ts +131 -0
  173. package/src/internal/events.ts +100 -0
  174. package/src/internal/history.ts +366 -0
  175. package/src/internal/instructions.ts +9 -0
  176. package/src/internal/loop.ts +344 -0
  177. package/src/internal/schema.ts +25 -0
  178. package/src/internal/skills.ts +37 -0
  179. package/src/internal/specialists.ts +414 -0
  180. package/src/internal/telemetry.ts +125 -0
  181. package/src/internal/toolkit.ts +231 -0
  182. package/src/internal/tools.ts +210 -0
  183. package/src/internal/turns.ts +429 -0
  184. package/src/internal/uuid.ts +50 -0
  185. package/src/internal/writer.ts +12 -0
package/src/Chats.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * This module provides the `Chats` service, through which the app tells the library which chats
3
+ * exist.
4
+ *
5
+ * The app keeps its own record of each chat (its owner, its title, whether it was deleted) and
6
+ * decides when a chat is created: up front, or on its first message. The library asks `Chats`
7
+ * before it stores an input and before each model call. When the app reports a chat as deleted,
8
+ * the library deletes it as well (see `Assistant.delete`).
9
+ *
10
+ * @since 0.1.0
11
+ */
12
+ import * as Context from "effect/Context"
13
+ import * as Effect from "effect/Effect"
14
+ import * as Schema from "effect/Schema"
15
+ import { ChatId } from "./ChatId.ts"
16
+
17
+ /**
18
+ * Schema for the reason a chat can't be used. `"NotFound"` means the app doesn't know the chat,
19
+ * and `"Deleted"` means the app deleted it.
20
+ *
21
+ * @category schemas
22
+ * @since 0.1.0
23
+ */
24
+ export const Reason = Schema.Literals(["NotFound", "Deleted"])
25
+
26
+ /**
27
+ * The reason a chat can't be used.
28
+ *
29
+ * @category models
30
+ * @since 0.1.0
31
+ */
32
+ export type Reason = typeof Reason.Type
33
+
34
+ /**
35
+ * Represents the error that `get` fails with when a chat can't be used. It carries the chat's id
36
+ * and the {@link Reason}. An input for such a chat isn't stored.
37
+ *
38
+ * **Example** (Failing for an unknown chat)
39
+ *
40
+ * ```ts
41
+ * if (row === undefined) return yield* new GetChatError({ chatId, reason: "NotFound" })
42
+ * ```
43
+ *
44
+ * @category errors
45
+ * @since 0.1.0
46
+ */
47
+ export class GetChatError extends Schema.Error<GetChatError>("effect-assistant/Chats/GetChatError")({
48
+ _tag: Schema.tag("GetChatError"),
49
+ chatId: ChatId,
50
+ reason: Reason
51
+ }) {}
52
+
53
+ /**
54
+ * Represents what the library needs to know about a chat, which is its id. The app's own record
55
+ * can carry more.
56
+ *
57
+ * @category models
58
+ * @since 0.1.0
59
+ */
60
+ export interface ChatRecord {
61
+ readonly chatId: ChatId
62
+ }
63
+
64
+ /**
65
+ * Represents the app's chats, as the `Chats` service exposes them.
66
+ *
67
+ * @category models
68
+ * @since 0.1.0
69
+ */
70
+ export interface Service {
71
+ /** Returns the chat, or fails with a `GetChatError` that says why it can't be used. */
72
+ readonly get: (chatId: ChatId) => Effect.Effect<ChatRecord, GetChatError>
73
+ }
74
+
75
+ /**
76
+ * Service that tells the library which of the app's chats exist.
77
+ *
78
+ * **Details**
79
+ *
80
+ * By default every chat exists from its first input, and a chat is deleted only when its log
81
+ * says so (`ChatDeleted`). Provide your own implementation to have the app decide. Provide it
82
+ * where you provide the `ChatLog`, so the engine and your server see the same one.
83
+ *
84
+ * **Example** (Reading chats from the app's own table)
85
+ *
86
+ * ```ts
87
+ * import { type ChatId, Chats } from "effect-assistant"
88
+ * import * as Effect from "effect/Effect"
89
+ * import * as Layer from "effect/Layer"
90
+ * import * as SqlClient from "effect/sql/SqlClient"
91
+ *
92
+ * const ChatsLive = Layer.effect(
93
+ * Chats.Chats,
94
+ * Effect.gen(function* () {
95
+ * const sql = yield* SqlClient.SqlClient
96
+ *
97
+ * return {
98
+ * get: Effect.fn(function* (chatId: ChatId.ChatId) {
99
+ * const [row] = yield* sql<{ readonly deleted_at: number | null }>`
100
+ * SELECT deleted_at FROM chats WHERE chat_id = ${chatId}
101
+ * `.pipe(Effect.orDie)
102
+ *
103
+ * if (row === undefined) {
104
+ * return yield* new Chats.GetChatError({ chatId, reason: "NotFound" })
105
+ * }
106
+ * if (row.deleted_at !== null) {
107
+ * return yield* new Chats.GetChatError({ chatId, reason: "Deleted" })
108
+ * }
109
+ *
110
+ * return { chatId }
111
+ * })
112
+ * }
113
+ * })
114
+ * )
115
+ * ```
116
+ *
117
+ * @category services
118
+ * @since 0.1.0
119
+ */
120
+ export const Chats: Context.Reference<Service> = Context.Reference<Service>("effect-assistant/Chats", {
121
+ defaultValue: () => ({ get: (chatId) => Effect.succeed({ chatId }) })
122
+ })
@@ -0,0 +1,224 @@
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 * as Decision from "./Decision.ts"
21
+ import { userText } from "./internal/history.ts"
22
+ import { toJsonText } from "./internal/schema.ts"
23
+
24
+ /**
25
+ * Represents the fields a summary event must have: its text and the last `seq` it covers.
26
+ *
27
+ * Entries after `throughSeq` are kept as they are, so an input that arrived while the summary was
28
+ * being written isn't lost.
29
+ *
30
+ * @category models
31
+ * @since 0.1.0
32
+ */
33
+ export interface Summary {
34
+ readonly _tag: string
35
+ readonly text: string
36
+ readonly throughSeq: number
37
+ }
38
+
39
+ /**
40
+ * Returns the number of input tokens used by the chat's last model call, which shows how large
41
+ * the prompt has grown. Returns 0 before the first call.
42
+ *
43
+ * @category utils
44
+ * @since 0.1.0
45
+ */
46
+ export const inputTokens = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): number => {
47
+ const last = entries.findLast(
48
+ ({ event }) =>
49
+ AssistantEvent.isLibraryEvent(event) && Predicate.isTagged(event, "ModelOutput") && event.usage !== undefined
50
+ )?.event
51
+
52
+ return last !== undefined && AssistantEvent.isLibraryEvent(last) && Predicate.isTagged(last, "ModelOutput")
53
+ ? (last.usage?.inputTokens.total ?? 0)
54
+ : 0
55
+ }
56
+
57
+ /**
58
+ * Returns the items that were open at `throughSeq` and are still open: decisions waiting for the
59
+ * user, blocks the user hasn't answered, and specialists that are working. The result is text for
60
+ * the model, or `None` when nothing is open.
61
+ *
62
+ * @category utils
63
+ * @since 0.1.0
64
+ */
65
+ export const openItems = (
66
+ entries: ReadonlyArray<AssistantEvent.AnyEntry>,
67
+ throughSeq: number
68
+ ): Option.Option<string> => {
69
+ const seqOf = (found: (event: AssistantEvent.AnyEntry["event"]) => boolean) =>
70
+ entries.find(({ event }) => found(event))?.seq ?? Number.POSITIVE_INFINITY
71
+
72
+ const opened = (seq: number) => seq <= throughSeq
73
+
74
+ const decisions = [...Decision.fromEntries(entries).values()].flatMap((state) =>
75
+ state.outcome === undefined &&
76
+ opened(
77
+ seqOf(
78
+ (event) =>
79
+ AssistantEvent.isLibraryEvent(event) &&
80
+ Predicate.isTagged(event, "DecisionRequested") &&
81
+ event.id === state.request.id
82
+ )
83
+ )
84
+ ? [`- decision ${state.request.id}, for ${state.request.origin.tool}: ${toJsonText(state.request.origin.params)}`]
85
+ : []
86
+ )
87
+
88
+ const blocks = [...BlockKit.fromEntries(entries)].flatMap(([messageId, state]) => {
89
+ const shown = entries.find(
90
+ ({ event }) =>
91
+ AssistantEvent.isLibraryEvent(event) && Predicate.isTagged(event, "AssistantMessage") && event.id === messageId
92
+ )
93
+
94
+ const message = shown?.event
95
+
96
+ return state.open &&
97
+ shown !== undefined &&
98
+ opened(shown.seq) &&
99
+ message !== undefined &&
100
+ AssistantEvent.isLibraryEvent(message) &&
101
+ Predicate.isTagged(message, "AssistantMessage")
102
+ ? [`- message ${messageId}, a ${message.block?.kind} block: ${message.text}`]
103
+ : []
104
+ })
105
+
106
+ const specialists = [...AssistantActivity.fromEntries(entries).specialists.values()].flatMap((run) =>
107
+ opened(
108
+ seqOf(
109
+ (event) =>
110
+ AssistantEvent.isLibraryEvent(event) &&
111
+ Predicate.isTagged(event, "SpecialistStarted") &&
112
+ event.runId === run.runId
113
+ )
114
+ )
115
+ ? [`- your ${run.specialist} specialist (${run.runId}), working on: ${run.goal}`]
116
+ : []
117
+ )
118
+
119
+ const sections = [
120
+ decisions.length > 0 ? `Decisions waiting for the user:\n${decisions.join("\n")}` : "",
121
+ blocks.length > 0 ? `Blocks the user hasn't answered:\n${blocks.join("\n")}` : "",
122
+ specialists.length > 0 ? `Specialists still working:\n${specialists.join("\n")}` : ""
123
+ ].filter((section) => section.length > 0)
124
+
125
+ return sections.length === 0 ? Option.none() : Option.some(sections.join("\n\n"))
126
+ }
127
+
128
+ /**
129
+ * Returns the files the user attached up to `throughSeq`, as lines of text for the model, or
130
+ * `None` when there are none. A summary may leave out a file's id, which tools need.
131
+ *
132
+ * @category utils
133
+ * @since 0.1.0
134
+ */
135
+ export const attachedFiles = (
136
+ entries: ReadonlyArray<AssistantEvent.AnyEntry>,
137
+ throughSeq: number
138
+ ): Option.Option<string> => {
139
+ const lines = entries.flatMap(({ seq, event }) =>
140
+ seq <= throughSeq && AssistantEvent.isLibraryEvent(event) && Predicate.isTagged(event, "UserMessage")
141
+ ? (event.attachments ?? []).map(AssistantEvent.attachmentLine)
142
+ : []
143
+ )
144
+
145
+ return lines.length === 0 ? Option.none() : Option.some(lines.join("\n"))
146
+ }
147
+
148
+ /**
149
+ * Creates a history view that starts at the last summary.
150
+ *
151
+ * **Details**
152
+ *
153
+ * `isSummary` identifies the app's summary events. The model reads the last summary, the files
154
+ * attached earlier ({@link attachedFiles}), and the items that are still open
155
+ * ({@link openItems}), followed by the entries after the summary. When the chat has no summary,
156
+ * the view returns every entry.
157
+ *
158
+ * **Example** (Compacting a long chat after each turn)
159
+ *
160
+ * ```ts
161
+ * import { AppEvent, Assistant, type AssistantEvent, Compaction } from "effect-assistant"
162
+ * import * as LanguageModel from "effect/ai/LanguageModel"
163
+ * import * as Effect from "effect/Effect"
164
+ * import * as Schema from "effect/Schema"
165
+ * import * as Stream from "effect/Stream"
166
+ *
167
+ * class Summary extends Schema.TaggedClass<Summary>()("Summary", {
168
+ * text: Schema.String,
169
+ * throughSeq: Schema.Int
170
+ * }) {}
171
+ *
172
+ * declare const transcript: (entries: ReadonlyArray<AssistantEvent.AnyEntry>) => string
173
+ *
174
+ * export const MyAssistant = Assistant.make("my-assistant", { events: [AppEvent.make(Summary)] })
175
+ *
176
+ * const MyAssistantLive = MyAssistant.toLayer((turn) => {
177
+ * const compact = Effect.gen(function* () {
178
+ * const chat = yield* MyAssistant.Chat
179
+ * const entries = yield* chat.entries
180
+ *
181
+ * if (Compaction.inputTokens(entries) < 100_000) return
182
+ *
183
+ * const { text } = yield* LanguageModel.generateText({
184
+ * prompt: `Summarize this chat:\n\n${transcript(entries)}`
185
+ * })
186
+ *
187
+ * yield* chat.append(Summary.make({ text, throughSeq: entries.at(-1)?.seq ?? 0 }))
188
+ * })
189
+ *
190
+ * // After the reply, so the user doesn't wait for the summary.
191
+ * return Stream.concat(
192
+ * turn.loop({ instructions: "Be brief.", history: Compaction.since(Schema.is(Summary)) }),
193
+ * Stream.drain(Stream.fromEffect(compact))
194
+ * )
195
+ * })
196
+ * ```
197
+ *
198
+ * @category utils
199
+ * @since 0.1.0
200
+ */
201
+ export const since =
202
+ (isSummary: (event: unknown) => event is Summary) =>
203
+ <Events extends { readonly _tag: string }>(
204
+ entries: ReadonlyArray<Assistant.Entry<Events>>
205
+ ): ReadonlyArray<Assistant.Entry<Events> | Prompt.Message> => {
206
+ const last = entries.findLast(({ event }) => isSummary(event))?.event
207
+
208
+ if (last === undefined || !isSummary(last)) return entries
209
+
210
+ const open = Option.match(openItems(entries, last.throughSeq), {
211
+ onNone: () => "",
212
+ onSome: (items) => `\n\n<still_open>\n${items}\n</still_open>`
213
+ })
214
+
215
+ const files = Option.match(attachedFiles(entries, last.throughSeq), {
216
+ onNone: () => "",
217
+ onSome: (lines) => `\n\n<files_attached_earlier>\n${lines}\n</files_attached_earlier>`
218
+ })
219
+
220
+ return [
221
+ userText(`<earlier_in_this_chat>\n${last.text}\n</earlier_in_this_chat>${files}${open}`),
222
+ ...entries.filter(({ seq, event }) => seq > last.throughSeq && !isSummary(event))
223
+ ]
224
+ }
@@ -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") {}
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Lets the user approve or decline a tool call the assistant can't make on its own, such as
3
+ * placing an order or publishing a post.
4
+ *
5
+ * When the model calls a tool marked with Effect's `needsApproval`, the call is parked instead of
6
+ * run. The model asks the user with the decision {@link block}, and the user's answer to that
7
+ * block runs the call or declines it. {@link fromEntries} computes where every decision in a
8
+ * chat's log stands.
9
+ *
10
+ * @since 0.1.0
11
+ */
12
+ import * as Option from "effect/Option"
13
+ import * as Predicate from "effect/Predicate"
14
+ import * as Schema from "effect/Schema"
15
+ import * as AssistantEvent from "./AssistantEvent.ts"
16
+ import * as Block from "./Block.ts"
17
+ import * as DecisionId from "./DecisionId.ts"
18
+ import type { InputId } from "./InputId.ts"
19
+
20
+ /**
21
+ * The kind of the decision block.
22
+ *
23
+ * @category utils
24
+ * @since 0.1.0
25
+ */
26
+ export const kind = "decision"
27
+
28
+ /**
29
+ * Schema for the decision block's props, which list the decisions it asks for and, optionally,
30
+ * its buttons' labels.
31
+ *
32
+ * @category schemas
33
+ * @since 0.1.0
34
+ */
35
+ export const Props = Schema.Struct({
36
+ // Plain strings: a strict tool schema with the id's pattern is too much for some providers; the
37
+ // ids are checked against the decisions waiting when the message is sent.
38
+ decisionIds: Schema.Array(Schema.String).check(Schema.isMinLength(1)).annotate({
39
+ description:
40
+ "The decisions to ask for: each waiting call's decisionId. Several go in one block when the user decides them together."
41
+ }),
42
+ approveLabel: Schema.optional(Schema.String).annotate({
43
+ description: 'The approve button, like "Place order".'
44
+ }),
45
+ declineLabel: Schema.optional(Schema.String).annotate({
46
+ description: 'The decline button, like "Cancel".'
47
+ })
48
+ })
49
+
50
+ /**
51
+ * Represents the decision block's props.
52
+ *
53
+ * @category models
54
+ * @since 0.1.0
55
+ */
56
+ export type Props = typeof Props.Type
57
+
58
+ /**
59
+ * Schema for the decision block's answer, which holds an outcome for every decision the block
60
+ * asks for, by id.
61
+ *
62
+ * @category schemas
63
+ * @since 0.1.0
64
+ */
65
+ export const Answer = Schema.Struct({
66
+ decisions: Schema.Record(Schema.String, AssistantEvent.DecisionOutcome)
67
+ })
68
+
69
+ /**
70
+ * Represents the decision block's answer.
71
+ *
72
+ * @category models
73
+ * @since 0.1.0
74
+ */
75
+ export type Answer = typeof Answer.Type
76
+
77
+ /**
78
+ * Block with approve and decline buttons for the decisions waiting for the user.
79
+ *
80
+ * **Details**
81
+ *
82
+ * Put it in the assistant's `BlockKit`. An assistant with a tool that needs a decision must have
83
+ * it. The client renders it like any other block, with the calls from {@link fromEntries} (their
84
+ * tool and params).
85
+ *
86
+ * **Example** (Asking the user before placing an order)
87
+ *
88
+ * ```ts
89
+ * import { Assistant, BlockKit, Decision } from "effect-assistant"
90
+ * import * as Tool from "effect/ai/Tool"
91
+ * import * as Toolkit from "effect/ai/Toolkit"
92
+ * import * as Effect from "effect/Effect"
93
+ * import * as Layer from "effect/Layer"
94
+ * import * as Schema from "effect/Schema"
95
+ *
96
+ * export const Shop = Assistant.make("shop", { blocks: BlockKit.make([Decision.block]) })
97
+ *
98
+ * const OrderTools = Toolkit.make(
99
+ * Tool.make("place_order", {
100
+ * description: "Places an order.",
101
+ * parameters: Schema.Struct({ sku: Schema.String, quantity: Schema.Int }),
102
+ * success: Schema.Struct({ reference: Schema.String }),
103
+ * needsApproval: true
104
+ * })
105
+ * )
106
+ *
107
+ * const OrderToolsLive = OrderTools.toLayer({
108
+ * place_order: () => Effect.succeed({ reference: "R-1042" })
109
+ * })
110
+ *
111
+ * // The call waits until the user answers the decision block.
112
+ * const ShopLive = Shop.toLayer((turn) =>
113
+ * turn.loop({ instructions: "You place orders for the team.", toolkit: OrderTools })
114
+ * ).pipe(Layer.provide(OrderToolsLive))
115
+ * ```
116
+ *
117
+ * @category constructors
118
+ * @since 0.1.0
119
+ */
120
+ export const block = Block.make(kind, {
121
+ description:
122
+ "Approve / decline buttons for decisions waiting for the user: a call of yours that waits for their decision. The only way such a decision is made: write what they decide on (the call's key facts), and put the decision ids in decisionIds.",
123
+ props: Props,
124
+ answer: Answer
125
+ })
126
+
127
+ /**
128
+ * Creates an answer that gives every decision the block asks for the same outcome.
129
+ *
130
+ * This is what an "Approve" or "Decline" button sends.
131
+ *
132
+ * @category constructors
133
+ * @since 0.1.0
134
+ */
135
+ export const answerAll = (props: Props, outcome: AssistantEvent.DecisionOutcome): Answer => ({
136
+ decisions: Object.fromEntries(props.decisionIds.map((id) => [id, outcome]))
137
+ })
138
+
139
+ /**
140
+ * Represents the state of a decision.
141
+ *
142
+ * @category models
143
+ * @since 0.1.0
144
+ */
145
+ export interface State {
146
+ readonly request: AssistantEvent.DecisionRequested
147
+ /** How the user decided, or `undefined` while the decision waits. */
148
+ readonly outcome: AssistantEvent.DecisionOutcome | undefined
149
+ /** The id of the answer that decided it. */
150
+ readonly answer: InputId | undefined
151
+ /** Whether its `DecisionResolved` is stored. A workflow's decision has been passed on by then. */
152
+ readonly resolved: boolean
153
+ /** The approved call's result, once the call has run. */
154
+ readonly result: AssistantEvent.ToolResult | undefined
155
+ }
156
+
157
+ const decodeAnswer = Schema.decodeUnknownOption(Answer)
158
+
159
+ /**
160
+ * Returns the decision states after one more event.
161
+ *
162
+ * @category utils
163
+ * @since 0.1.0
164
+ */
165
+ export const apply = (
166
+ states: ReadonlyMap<string, State>,
167
+ event: { readonly _tag: string }
168
+ ): ReadonlyMap<string, State> => {
169
+ if (!AssistantEvent.isLibraryEvent(event)) return states
170
+
171
+ const update = (id: string, f: (state: State) => State) => {
172
+ const state = states.get(id)
173
+
174
+ return state === undefined ? states : new Map(states).set(id, f(state))
175
+ }
176
+
177
+ if (Predicate.isTagged(event, "DecisionRequested")) {
178
+ return states.has(event.id)
179
+ ? states
180
+ : new Map(states).set(event.id, {
181
+ request: event,
182
+ outcome: undefined,
183
+ answer: undefined,
184
+ resolved: false,
185
+ result: undefined
186
+ })
187
+ }
188
+
189
+ if (Predicate.isTagged(event, "DecisionResolved")) {
190
+ return update(event.id, (state) => ({
191
+ ...state,
192
+ outcome: event.outcome,
193
+ answer: event.answer,
194
+ resolved: true
195
+ }))
196
+ }
197
+
198
+ // A stored decision answer was checked against its block when it was submitted: the first one
199
+ // for a decision decides it.
200
+ if (Predicate.isTagged(event, "BlockAnswer") && event.kind === kind) {
201
+ return Option.match(decodeAnswer(event.value), {
202
+ onNone: () => states,
203
+ onSome: ({ decisions }) => {
204
+ const next = new Map(states)
205
+
206
+ for (const [id, outcome] of Object.entries(decisions)) {
207
+ const state = states.get(id)
208
+
209
+ if (state !== undefined && state.outcome === undefined) next.set(id, { ...state, outcome, answer: event.id })
210
+ }
211
+
212
+ return next
213
+ }
214
+ })
215
+ }
216
+
217
+ if (Predicate.isTagged(event, "ToolResult")) {
218
+ const id = DecisionId.fromCallId(event.callId)
219
+ const state = states.get(id)
220
+
221
+ return state !== undefined && Predicate.isTagged(state.request.origin, "Call") && state.outcome === "approved"
222
+ ? update(id, (current) => ({ ...current, result: event }))
223
+ : states
224
+ }
225
+
226
+ return states
227
+ }
228
+
229
+ /**
230
+ * Returns every decision in the log and where it stands, by id.
231
+ *
232
+ * @category utils
233
+ * @since 0.1.0
234
+ */
235
+ export const fromEntries = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): ReadonlyMap<string, State> => {
236
+ let all: ReadonlyMap<string, State> = new Map()
237
+
238
+ for (const entry of entries) all = apply(all, entry.event)
239
+
240
+ return all
241
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * This module provides the id of a decision the user is asked to make, which is `decision:`
3
+ * followed by a UUID.
4
+ *
5
+ * The model passes the id to a decision block, and the user's answer refers to it.
6
+ *
7
+ * @since 0.1.0
8
+ */
9
+ import * as Schema from "effect/Schema"
10
+ import * as Uuid from "./internal/uuid.ts"
11
+
12
+ /**
13
+ * Schema for decision ids of the form `` `decision:${uuid}` ``, branded.
14
+ *
15
+ * @category schemas
16
+ * @since 0.1.0
17
+ */
18
+ export const DecisionId = Schema.TemplateLiteral(["decision:", Uuid.Uuid]).pipe(
19
+ Schema.brand("~effect-assistant/DecisionId")
20
+ )
21
+
22
+ /**
23
+ * Represents a decision id.
24
+ *
25
+ * @category models
26
+ * @since 0.1.0
27
+ */
28
+ export type DecisionId = typeof DecisionId.Type
29
+
30
+ /**
31
+ * Creates the decision id for a key. The same key always gives the same id, so a decision that is
32
+ * requested again is stored only once.
33
+ *
34
+ * @category constructors
35
+ * @since 0.1.0
36
+ */
37
+ export const fromKey = (key: string): DecisionId => DecisionId.make(`decision:${Uuid.fromKey(key)}`)
38
+
39
+ /**
40
+ * Returns the decision id for a tool call that waits for the user's decision, derived from the
41
+ * call's id.
42
+ *
43
+ * @category constructors
44
+ * @since 0.1.0
45
+ */
46
+ export const fromCallId = (callId: string): DecisionId => fromKey(`tool-call:${callId}`)