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/index.ts ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * effect-assistant: a colleague assistant on Effect.
3
+ *
4
+ * @since 0.1.0
5
+ */
6
+
7
+ /**
8
+ * @since 0.1.0
9
+ */
10
+ export * as AppEvent from "./AppEvent.ts"
11
+
12
+ /**
13
+ * @since 0.1.0
14
+ */
15
+ export * as Assistant from "./Assistant.ts"
16
+
17
+ /**
18
+ * @since 0.1.0
19
+ */
20
+ export * as AssistantActivity from "./AssistantActivity.ts"
21
+
22
+ /**
23
+ * @since 0.1.0
24
+ */
25
+ export * as AssistantEngine from "./AssistantEngine.ts"
26
+
27
+ /**
28
+ * @since 0.1.0
29
+ */
30
+ export * as AssistantEvent from "./AssistantEvent.ts"
31
+
32
+ /**
33
+ * @since 0.1.0
34
+ */
35
+ export * as AssistantLoop from "./AssistantLoop.ts"
36
+
37
+ /**
38
+ * @since 0.1.0
39
+ */
40
+ export * as AssistantToolCall from "./AssistantToolCall.ts"
41
+
42
+ /**
43
+ * @since 0.1.0
44
+ */
45
+ export * as Block from "./Block.ts"
46
+
47
+ /**
48
+ * @since 0.1.0
49
+ */
50
+ export * as BlockKit from "./BlockKit.ts"
51
+
52
+ /**
53
+ * @since 0.1.0
54
+ */
55
+ export * as ChatId from "./ChatId.ts"
56
+
57
+ /**
58
+ * @since 0.1.0
59
+ */
60
+ export * as ChatLog from "./ChatLog.ts"
61
+
62
+ /**
63
+ * @since 0.1.0
64
+ */
65
+ export * as Chats from "./Chats.ts"
66
+
67
+ /**
68
+ * @since 0.1.0
69
+ */
70
+ export * as ChatView from "./ChatView.ts"
71
+
72
+ /**
73
+ * @since 0.1.0
74
+ */
75
+ export * as Compaction from "./Compaction.ts"
76
+
77
+ /**
78
+ * @since 0.1.0
79
+ */
80
+ export * as CurrentChat from "./CurrentChat.ts"
81
+
82
+ /**
83
+ * @since 0.1.0
84
+ */
85
+ export * as Decision from "./Decision.ts"
86
+
87
+ /**
88
+ * @since 0.1.0
89
+ */
90
+ export * as DecisionId from "./DecisionId.ts"
91
+
92
+ /**
93
+ * @since 0.1.0
94
+ */
95
+ export * as InputId from "./InputId.ts"
96
+
97
+ /**
98
+ * @since 0.1.0
99
+ */
100
+ export * as Instructions from "./Instructions.ts"
101
+
102
+ /**
103
+ * @since 0.1.0
104
+ */
105
+ export * as Memory from "./Memory.ts"
106
+
107
+ /**
108
+ * @since 0.1.0
109
+ */
110
+ export * as RunId from "./RunId.ts"
111
+
112
+ /**
113
+ * @since 0.1.0
114
+ */
115
+ export * as Skill from "./Skill.ts"
116
+
117
+ /**
118
+ * @since 0.1.0
119
+ */
120
+ export * as Specialist from "./Specialist.ts"
121
+
122
+ /**
123
+ * @since 0.1.0
124
+ */
125
+ export * as ToolDiscovery from "./ToolDiscovery.ts"
126
+
127
+ /**
128
+ * @since 0.1.0
129
+ */
130
+ export * as ToolFailure from "./ToolFailure.ts"
@@ -0,0 +1,372 @@
1
+ import * as Cause from "effect/Cause"
2
+ import * as Context from "effect/Context"
3
+ import * as Deferred from "effect/Deferred"
4
+ import * as Effect from "effect/Effect"
5
+ import * as Exit from "effect/Exit"
6
+ import * as Fiber from "effect/Fiber"
7
+ import * as FiberSet from "effect/FiberSet"
8
+ import * as Option from "effect/Option"
9
+ import * as Predicate from "effect/Predicate"
10
+ import * as PubSub from "effect/PubSub"
11
+ import * as Ref from "effect/Ref"
12
+ import * as Scope from "effect/Scope"
13
+ import * as Semaphore from "effect/Semaphore"
14
+ import * as Stream from "effect/Stream"
15
+ import * as SynchronizedRef from "effect/SynchronizedRef"
16
+ import * as AssistantEvent from "../AssistantEvent.ts"
17
+ import type { ChatId } from "../ChatId.ts"
18
+ import { ChatLog } from "../ChatLog.ts"
19
+ import { CurrentChat } from "../CurrentChat.ts"
20
+ import type { InputId } from "../InputId.ts"
21
+ import * as Chats from "../Chats.ts"
22
+ import * as SpecialistService from "../Specialist.ts"
23
+ import * as Decisions from "./decisions.ts"
24
+ import * as AppEvents from "./events.ts"
25
+ import * as History from "./history.ts"
26
+ import * as Specialists from "./specialists.ts"
27
+ import * as Turns from "./turns.ts"
28
+ import type { Writer } from "./writer.ts"
29
+
30
+ const idOf = (event: { readonly _tag: string }): string | undefined =>
31
+ AssistantEvent.isLibraryEvent(event)
32
+ ? AssistantEvent.isInput(event)
33
+ ? event.id
34
+ : undefined
35
+ : Predicate.hasProperty(event, "id") && Predicate.isString(event.id)
36
+ ? event.id
37
+ : undefined
38
+
39
+ const makeChannelPubSub = Effect.acquireRelease(PubSub.unbounded<AssistantEvent.Entry>(), PubSub.shutdown)
40
+
41
+ const makeHolding = Effect.fnUntraced(function* (keepAlive: (enabled: boolean) => Effect.Effect<void>) {
42
+ const holds = yield* SynchronizedRef.make(0)
43
+
44
+ const acquire = SynchronizedRef.updateEffect(
45
+ holds,
46
+ Effect.fnUntraced(function* (n: number) {
47
+ if (n !== 0) return n + 1
48
+
49
+ yield* keepAlive(true)
50
+
51
+ return 1
52
+ })
53
+ )
54
+
55
+ const release = SynchronizedRef.updateEffect(
56
+ holds,
57
+ Effect.fnUntraced(function* (n: number) {
58
+ if (n !== 1) return n - 1
59
+
60
+ yield* keepAlive(false)
61
+
62
+ return 0
63
+ })
64
+ )
65
+
66
+ return (effect: Effect.Effect<void>) =>
67
+ Effect.acquireUseRelease(
68
+ acquire,
69
+ () => effect,
70
+ // An interrupted effect (a shutdown) keeps its hold, so the chat starts again and picks up where it was cut off.
71
+ (_, exit) => (Exit.hasInterrupts(exit) ? Effect.void : release)
72
+ )
73
+ })
74
+
75
+ /** @internal */
76
+ export interface Chat {
77
+ readonly submit: (input: AssistantEvent.Input | AssistantEvent.StoredAppEvent) => Effect.Effect<void>
78
+ readonly submitInBackground: (input: AssistantEvent.Input | AssistantEvent.StoredAppEvent) => Effect.Effect<void>
79
+ // For inputs no turn has read, like a report stored during a shutdown.
80
+ readonly resume: Effect.Effect<void>
81
+ readonly stream: (after: number) => Stream.Stream<AssistantEvent.Entry>
82
+ // Stops what works in the chat and ends its subscribers' streams; its log stays.
83
+ readonly delete: Effect.Effect<void>
84
+ }
85
+
86
+ const isChatDeleted = (entry: AssistantEvent.Entry) => Predicate.isTagged(entry.event, "ChatDeleted")
87
+
88
+ const deletedChat = (entries: ReadonlyArray<AssistantEvent.Entry>): Chat => ({
89
+ submit: () => Effect.void,
90
+ submitInBackground: () => Effect.void,
91
+ resume: Effect.void,
92
+ stream: (after) => Stream.fromIterable(entries.filter((entry) => entry.seq > after)),
93
+ delete: Effect.void
94
+ })
95
+
96
+ const refused = (input: { readonly _tag: string }, why: string) =>
97
+ Effect.logDebug(`assistant chat ${why}: ${input._tag} not stored`)
98
+
99
+ /** @internal */
100
+ export const make = Effect.fnUntraced(function* (options: {
101
+ readonly registration: Turns.Registration
102
+ readonly chatId: ChatId
103
+ readonly keepAlive: (enabled: boolean) => Effect.Effect<void>
104
+ }) {
105
+ const { registration, chatId } = options
106
+ const { app } = registration.definition
107
+ const { wakes } = app
108
+ const storage = yield* ChatLog
109
+ const chats = yield* Chats.Chats
110
+ const initial = yield* storage.read(chatId)
111
+
112
+ if (initial.some(isChatDeleted)) return deletedChat(initial)
113
+
114
+ const channel = yield* makeChannelPubSub
115
+ const writes = yield* Semaphore.make(1)
116
+ // Inputs are stored one at a time with the decisions they make: an answer checks the decisions the last one made.
117
+ const storing = yield* Semaphore.make(1)
118
+ const turnsLock = yield* Semaphore.make(1)
119
+ const holding = yield* makeHolding(options.keepAlive)
120
+ const deleted = yield* Deferred.make<undefined>()
121
+ // So deleting the chat stops all its work: its turns, runs, and the builds' resources.
122
+ const working = yield* Scope.fork(yield* Effect.scope)
123
+ const inWorking = Effect.provideService(Scope.Scope, working)
124
+ const background = yield* inWorking(FiberSet.make())
125
+ const requests = yield* inWorking(FiberSet.make())
126
+
127
+ const isDeleted = Deferred.isDone(deleted)
128
+
129
+ // The chat is its log's only writer, so it reads the log once and turns read memory.
130
+ const entries = yield* Ref.make(initial)
131
+
132
+ // Every writer goes through here, one at a time, so each append is numbered after the last. After a delete it
133
+ // stores nothing.
134
+ const write = Effect.fnUntraced(function* (
135
+ events: ReadonlyArray<AssistantEvent.AssistantEvent | AssistantEvent.StoredAppEvent>
136
+ ) {
137
+ const unknown = events.find((event) => !AssistantEvent.isLibraryEvent(event) && !AppEvents.isRegistered(app, event))
138
+
139
+ if (unknown !== undefined) {
140
+ return yield* Effect.die(`${unknown._tag} isn't one of the ${registration.definition.name} assistant's events.`)
141
+ }
142
+
143
+ if (yield* isDeleted) return []
144
+
145
+ return yield* stored(events)
146
+ })
147
+
148
+ // Uninterruptible: a stored append must reach memory and the subscribers too, or a turn misses it and replies twice.
149
+ const stored = Effect.fnUntraced(function* (
150
+ events: ReadonlyArray<AssistantEvent.AssistantEvent | AssistantEvent.StoredAppEvent>
151
+ ) {
152
+ const stored = yield* storage.append(chatId, events)
153
+
154
+ yield* Ref.update(entries, (all) => [...all, ...stored])
155
+ yield* PubSub.publishAll(channel, stored)
156
+
157
+ return stored
158
+ }, Effect.uninterruptible)
159
+
160
+ const chat: Writer = {
161
+ chatId,
162
+ entries: Ref.get(entries),
163
+ append: (events) => Semaphore.withPermit(writes)(write(events))
164
+ }
165
+
166
+ const toStored = (event: {
167
+ readonly _tag: string
168
+ }): Effect.Effect<AssistantEvent.AssistantEvent | AssistantEvent.StoredAppEvent> =>
169
+ AssistantEvent.isLibraryEvent(event)
170
+ ? Effect.succeed(event)
171
+ : AppEvents.isRegistered(app, event)
172
+ ? AppEvents.encode(app, event)
173
+ : Effect.die(`${event._tag} isn't one of the ${registration.definition.name} assistant's events.`)
174
+
175
+ // What handlers, tools, and runs see: the assistant's own events decoded, and encoded when appended.
176
+ const handle: Turns.TypedChat = {
177
+ chatId,
178
+ entries: Effect.map(chat.entries, (all) => AppEvents.decodeEntries(app, all)),
179
+ append: (...events) => Effect.asVoid(Effect.flatMap(Effect.forEach(events, toStored), chat.append))
180
+ }
181
+
182
+ // An approved call runs at the turn's next step.
183
+ const resolve = Effect.fnUntraced(function* (decided: ReadonlyArray<Decisions.Decided>, answer: InputId) {
184
+ yield* chat.append(
185
+ decided.map(({ request, outcome }) => AssistantEvent.DecisionResolved.make({ id: request.id, outcome, answer }))
186
+ )
187
+ })
188
+
189
+ // A chat the app deleted is deleted here too, which stops what works in it: a turn that checks stops there.
190
+ const checkAvailable: Effect.Effect<boolean> = chats.get(chatId).pipe(
191
+ Effect.as(true),
192
+ Effect.catchTag("GetChatError", (unavailable) =>
193
+ unavailable.reason === "Deleted"
194
+ ? Effect.as(
195
+ Effect.suspend(() => remove),
196
+ false
197
+ )
198
+ : Effect.succeed(false)
199
+ )
200
+ )
201
+
202
+ // Under `storing`: an answer's decisions are resolved before the next input is stored.
203
+ const store = Effect.fnUntraced(function* (input: AssistantEvent.Input | AssistantEvent.StoredAppEvent) {
204
+ if (yield* isDeleted) return yield* refused(input, "is deleted")
205
+
206
+ if (!(yield* checkAvailable)) return yield* refused(input, "is unavailable")
207
+
208
+ // Checked under the write lock: a retry that arrives while the first submit is storing is stored once.
209
+ const verdict = yield* Semaphore.withPermit(writes)(
210
+ Effect.gen(function* () {
211
+ if (yield* isDeleted) {
212
+ yield* refused(input, "is deleted")
213
+
214
+ return Option.none()
215
+ }
216
+
217
+ const stored = yield* Ref.get(entries)
218
+ const id = idOf(input)
219
+
220
+ if (id !== undefined && stored.some(({ event }) => idOf(event) === id)) return Option.none()
221
+
222
+ const verdict = Decisions.verdict(stored, input)
223
+
224
+ if (Predicate.isTagged(verdict, "Refuse")) {
225
+ yield* Effect.logWarning(`assistant chat refused ${input._tag}: ${verdict.reason}`)
226
+
227
+ return Option.none()
228
+ }
229
+
230
+ yield* write([input])
231
+
232
+ return Option.some(verdict)
233
+ })
234
+ )
235
+
236
+ if (Option.isSome(verdict) && Predicate.isTagged(verdict.value, "Decide")) {
237
+ yield* resolve(verdict.value.decided, verdict.value.answer)
238
+ }
239
+ }, Semaphore.withPermit(storing))
240
+
241
+ // A restart between storing an answer and resolving it: resolve it now, before anything reads the chat.
242
+ yield* Effect.forEach(
243
+ Decisions.unresolved(yield* Ref.get(entries)),
244
+ (decided) => resolve([decided], decided.answer),
245
+ { discard: true }
246
+ )
247
+
248
+ // The handlers' builds run in the chat's working scope: what they set up lives until the chat closes or is deleted.
249
+ const handler = yield* inWorking(Effect.provideService(registration.open(handle), CurrentChat, handle))
250
+
251
+ const specialists = yield* Effect.forEach(
252
+ registration.specialists,
253
+ Effect.fnUntraced(function* ({ specialist, built }) {
254
+ const handler = yield* inWorking(Effect.provideService(built.open, CurrentChat, handle))
255
+
256
+ return { specialist, handler }
257
+ })
258
+ )
259
+
260
+ const inBackground = (effect: Effect.Effect<void>) => FiberSet.run(background, holding(effect)).pipe(Effect.asVoid)
261
+
262
+ // A request's turns run in the working scope too, so a delete stops them; the request ends quietly then.
263
+ const inRequest = Effect.fnUntraced(function* (effect: Effect.Effect<void>) {
264
+ const fiber = yield* FiberSet.run(requests, effect)
265
+ const exit = yield* Fiber.await(fiber).pipe(Effect.onInterrupt(() => Fiber.interrupt(fiber)))
266
+
267
+ if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) {
268
+ return yield* Effect.failCause(exit.cause)
269
+ }
270
+ }, Effect.orDie)
271
+
272
+ const drain: Effect.Effect<void> = Semaphore.withPermit(turnsLock)(
273
+ Effect.gen(function* () {
274
+ while (!(yield* isDeleted) && History.needsTurn(yield* chat.entries, wakes)) {
275
+ yield* Turns.run({ registration, handler, chat, services, checkOpen: Effect.asVoid(checkAvailable) })
276
+ }
277
+ })
278
+ )
279
+
280
+ const runner = yield* inWorking(
281
+ Specialists.makeRunner({
282
+ chat,
283
+ handle,
284
+ specialists,
285
+ model: registration.model,
286
+ holding,
287
+ report: Effect.fnUntraced(function* (finished) {
288
+ yield* store(finished)
289
+ yield* inBackground(drain)
290
+ })
291
+ })
292
+ )
293
+
294
+ const services = Context.make(CurrentChat, handle).pipe(
295
+ Context.add(SpecialistService.Runner, runner),
296
+ Context.add(
297
+ Decisions.TurnDecisions,
298
+ Option.some({
299
+ approved: Effect.gen(function* () {
300
+ const all = yield* chat.entries
301
+
302
+ return Decisions.approvedCalls(all)
303
+ })
304
+ })
305
+ ),
306
+ Context.merge(registration.model)
307
+ )
308
+
309
+ const remove = Effect.gen(function* () {
310
+ // Under the write lock: an append in flight is stored before the `ChatDeleted`; none comes after.
311
+ const first = yield* Semaphore.withPermit(writes)(
312
+ Effect.gen(function* () {
313
+ if (yield* isDeleted) return false
314
+
315
+ yield* stored([AssistantEvent.ChatDeleted.make({})])
316
+
317
+ return yield* Deferred.succeed(deleted, undefined)
318
+ })
319
+ ).pipe(Effect.uninterruptible)
320
+
321
+ if (!first) return
322
+
323
+ // Its own fiber: a tool that deletes its own chat is in the working scope, and closing it interrupts that tool.
324
+ // Interrupted work keeps its hold (see `makeHolding`), so the chat lets go of it here.
325
+ const closing = yield* Effect.forkDetach(
326
+ Effect.gen(function* () {
327
+ yield* Scope.close(working, Exit.void)
328
+ yield* options.keepAlive(false)
329
+ })
330
+ )
331
+
332
+ yield* Fiber.await(closing)
333
+ })
334
+
335
+ // A chat the app deleted while it was closed is deleted now, so its subscribers hear it.
336
+ yield* checkAvailable
337
+
338
+ const chatApi: Chat = {
339
+ submit: Effect.fnUntraced(function* (input) {
340
+ yield* store(input)
341
+ yield* inRequest(drain)
342
+ }),
343
+ submitInBackground: Effect.fnUntraced(function* (input) {
344
+ yield* store(input)
345
+ yield* inBackground(drain)
346
+ }),
347
+ resume: Effect.gen(function* () {
348
+ if (History.needsTurn(yield* chat.entries, wakes)) yield* inBackground(drain)
349
+ }),
350
+ stream: (after) =>
351
+ Stream.unwrap(
352
+ Effect.gen(function* () {
353
+ // Subscribes before it reads, so nothing appended in between is missed.
354
+ const subscription = yield* PubSub.subscribe(channel)
355
+ const all = yield* chat.entries
356
+ const stored = all.filter((entry) => entry.seq > after)
357
+ const last = stored.at(-1)?.seq ?? after
358
+
359
+ // Deleted: what the reader hasn't seen, then the end. Otherwise the stream ends with the `ChatDeleted`.
360
+ if (all.some(isChatDeleted)) return Stream.fromIterable(stored)
361
+
362
+ return Stream.concat(
363
+ Stream.fromIterable(stored),
364
+ Stream.fromSubscription(subscription).pipe(Stream.filter((entry) => entry.seq > last))
365
+ ).pipe(Stream.takeUntil(isChatDeleted))
366
+ })
367
+ ),
368
+ delete: remove
369
+ }
370
+
371
+ return chatApi
372
+ })
@@ -0,0 +1,131 @@
1
+ import * as Response from "effect/ai/Response"
2
+ import * as Context from "effect/Context"
3
+ import * as Data from "effect/Data"
4
+ import type * as Effect from "effect/Effect"
5
+ import * as Option from "effect/Option"
6
+ import * as Predicate from "effect/Predicate"
7
+ import * as Schema from "effect/Schema"
8
+ import * as AssistantEvent from "../AssistantEvent.ts"
9
+ import * as BlockKit from "../BlockKit.ts"
10
+ import * as Decision from "../Decision.ts"
11
+ import type { InputId } from "../InputId.ts"
12
+
13
+ // Only a turn's loop parks calls and runs approved ones. A turn's specialists inherit its context, so their runs set
14
+ // it back to none.
15
+ /** @internal */
16
+ export const TurnDecisions = Context.Reference<
17
+ Option.Option<{
18
+ readonly approved: Effect.Effect<ReadonlyArray<AssistantEvent.ToolCallPart>>
19
+ }>
20
+ >("effect-assistant/TurnDecisions", { defaultValue: Option.none })
21
+
22
+ /** @internal */
23
+ export interface Decided {
24
+ readonly request: AssistantEvent.DecisionRequested
25
+ readonly outcome: AssistantEvent.DecisionOutcome
26
+ }
27
+
28
+ /** @internal */
29
+ export type Verdict = Data.TaggedEnum<{
30
+ Store: Record<never, never>
31
+ Refuse: { readonly reason: string }
32
+ Decide: { readonly decided: ReadonlyArray<Decided>; readonly answer: InputId }
33
+ }>
34
+
35
+ const Verdicts = Data.taggedEnum<Verdict>()
36
+
37
+ const store = Verdicts.Store()
38
+
39
+ const refuse = (reason: string) => Verdicts.Refuse({ reason })
40
+
41
+ const decodeProps = Schema.decodeUnknownOption(Decision.Props)
42
+
43
+ const decodeAnswer = Schema.decodeUnknownOption(Decision.Answer)
44
+
45
+ const messageOf = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, id: string) =>
46
+ entries
47
+ .map(({ event }) => event)
48
+ .find(
49
+ (event): event is AssistantEvent.AssistantMessage =>
50
+ AssistantEvent.isLibraryEvent(event) && Predicate.isTagged(event, "AssistantMessage") && event.id === id
51
+ )
52
+
53
+ const answerVerdict = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, answer: AssistantEvent.BlockAnswer) => {
54
+ const message = messageOf(entries, answer.messageId)
55
+ const onDecisionBlock = message?.block?.kind === Decision.kind
56
+
57
+ if (!onDecisionBlock && answer.kind !== Decision.kind) return store
58
+
59
+ if (message?.block === undefined || !onDecisionBlock || answer.kind !== Decision.kind) {
60
+ return refuse(`${answer.messageId} has no decision block.`)
61
+ }
62
+
63
+ if (BlockKit.fromEntries(entries).get(message.id)?.open !== true)
64
+ return refuse(`The block on ${message.id} is closed.`)
65
+
66
+ const props = decodeProps(message.block.props)
67
+ const value = decodeAnswer(answer.value)
68
+
69
+ if (Option.isNone(props) || Option.isNone(value)) return refuse(`That isn't an answer to the block on ${message.id}.`)
70
+
71
+ const asked = props.value.decisionIds
72
+ const given = Object.keys(value.value.decisions)
73
+
74
+ if (given.length !== asked.length || !given.every((id) => asked.some((askedId) => askedId === id))) {
75
+ return refuse(`An answer decides exactly the decisions its block asks for: ${asked.join(", ")}.`)
76
+ }
77
+
78
+ const states = Decision.fromEntries(entries)
79
+ const decided: Array<Decided> = []
80
+
81
+ for (const id of asked) {
82
+ const state = states.get(id)
83
+ const outcome = value.value.decisions[id]
84
+
85
+ if (state === undefined || outcome === undefined) return refuse(`No decision ${id}.`)
86
+
87
+ if (state.outcome !== undefined) return refuse(`${id} is already decided.`)
88
+
89
+ decided.push({ request: state.request, outcome })
90
+ }
91
+
92
+ return Verdicts.Decide({ decided, answer: answer.id })
93
+ }
94
+
95
+ /** @internal */
96
+ export const verdict = (
97
+ entries: ReadonlyArray<AssistantEvent.AnyEntry>,
98
+ input: AssistantEvent.Input | AssistantEvent.StoredAppEvent
99
+ ): Verdict =>
100
+ AssistantEvent.isLibraryEvent(input) && Predicate.isTagged(input, "BlockAnswer")
101
+ ? answerVerdict(entries, input)
102
+ : store
103
+
104
+ // A restart can cut the chat off between storing an answer and resolving what it decided.
105
+ /** @internal */
106
+ export const unresolved = (
107
+ entries: ReadonlyArray<AssistantEvent.AnyEntry>
108
+ ): ReadonlyArray<Decided & { readonly answer: InputId }> =>
109
+ [...Decision.fromEntries(entries).values()].flatMap((state) =>
110
+ state.outcome !== undefined && state.answer !== undefined && !state.resolved
111
+ ? [{ request: state.request, outcome: state.outcome, answer: state.answer }]
112
+ : []
113
+ )
114
+
115
+ // They run with the ids and params the model gave them, before the model's next step.
116
+ /** @internal */
117
+ export const approvedCalls = (
118
+ entries: ReadonlyArray<AssistantEvent.AnyEntry>
119
+ ): ReadonlyArray<AssistantEvent.ToolCallPart> =>
120
+ [...Decision.fromEntries(entries).values()].flatMap(({ request, outcome, resolved, result }) =>
121
+ resolved && outcome === "approved" && result === undefined
122
+ ? [
123
+ Response.makePart("tool-call", {
124
+ id: request.origin.callId,
125
+ name: request.origin.tool,
126
+ params: request.origin.params,
127
+ providerExecuted: false
128
+ })
129
+ ]
130
+ : []
131
+ )