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
@@ -0,0 +1,231 @@
1
+ import * as AiError from "effect/ai/AiError"
2
+ import type * as Prompt from "effect/ai/Prompt"
3
+ import type * as Tool from "effect/ai/Tool"
4
+ import type * as Toolkit from "effect/ai/Toolkit"
5
+ import type * as Context from "effect/Context"
6
+ import * as Effect from "effect/Effect"
7
+ import * as Option from "effect/Option"
8
+ import * as Predicate from "effect/Predicate"
9
+ import * as Schema from "effect/Schema"
10
+ import * as Stream from "effect/Stream"
11
+ import type * as AssistantEvent from "../AssistantEvent.ts"
12
+ import { AssistantToolCall } from "../AssistantToolCall.ts"
13
+ import * as ToolFailure from "../ToolFailure.ts"
14
+ import { toJson } from "./schema.ts"
15
+
16
+ /** @internal */
17
+ export interface CallResult {
18
+ readonly outcome: AssistantEvent.ToolOutcome
19
+ readonly result: Schema.Json
20
+ readonly reason?: string | undefined
21
+ }
22
+
23
+ // Apps give the loop native toolkits; these are what the loop makes of them, merged with its own.
24
+ /** @internal */
25
+ export interface Tools<out R = never> {
26
+ readonly definitions: ReadonlyArray<Tool.Any>
27
+ readonly run: (call: AssistantEvent.ToolCallPart) => Effect.Effect<CallResult, never, R>
28
+ // Unset: no call waits for the user's decision.
29
+ readonly needsDecision?:
30
+ | ((call: AssistantEvent.ToolCallPart, history: ReadonlyArray<Prompt.Message>) => Effect.Effect<boolean, never, R>)
31
+ | undefined
32
+ }
33
+
34
+ /** @internal */
35
+ export type ToolServices<T extends Record<string, Tool.Any>> =
36
+ | Exclude<Tool.HandlerServices<T[keyof T]>, AssistantToolCall>
37
+ // Already in the handlers' services; stated for the loop, which decodes params to decide on a call.
38
+ | T[keyof T]["parametersSchema"]["DecodingServices"]
39
+
40
+ const encodeFailure = Schema.encodeSync(ToolFailure.ToolFailure)
41
+
42
+ /** @internal */
43
+ export const failed = (message: string, reason: string): CallResult => ({
44
+ outcome: "failure",
45
+ result: toJson(encodeFailure(new ToolFailure.ToolFailure({ message, reason }))),
46
+ reason
47
+ })
48
+
49
+ const decodeReason = Schema.decodeUnknownOption(Schema.Struct({ reason: Schema.String }))
50
+
51
+ const decodeTag = Schema.decodeUnknownOption(Schema.Struct({ _tag: Schema.String }))
52
+
53
+ const failure = (result: Schema.Json): CallResult => ({
54
+ outcome: "failure",
55
+ result,
56
+ reason: Option.getOrUndefined(
57
+ Option.orElse(
58
+ Option.map(decodeReason(result), ({ reason }) => reason),
59
+ () => Option.map(decodeTag(result), ({ _tag }) => _tag)
60
+ )
61
+ )
62
+ })
63
+
64
+ const hasTool = <T extends Record<string, Tool.Any>>(
65
+ toolkit: { readonly tools: T },
66
+ name: PropertyKey
67
+ ): name is keyof T => Object.hasOwn(toolkit.tools, name)
68
+
69
+ // With noUncheckedIndexedAccess, an index doesn't narrow on `hasTool`.
70
+ const toolNamed = (toolkit: { readonly tools: Record<string, Tool.Any> }, name: string): Tool.Any | undefined =>
71
+ Object.hasOwn(toolkit.tools, name) ? toolkit.tools[name] : undefined
72
+
73
+ const decodeParams = (tool: Tool.Any, params: Schema.Json) => Schema.decodeUnknownEffect(tool.parametersSchema)(params)
74
+
75
+ // Params that don't fit the tool don't park: the call runs, and fails with why.
76
+ /** @internal */
77
+ export const needsDecision = Effect.fnUntraced(function* <T extends Record<string, Tool.Any>>(
78
+ toolkit: { readonly tools: T },
79
+ call: AssistantEvent.ToolCallPart,
80
+ history: ReadonlyArray<Prompt.Message>
81
+ ): Effect.fn.Return<boolean, never, ToolServices<T>> {
82
+ const tool = toolNamed(toolkit, call.name)
83
+
84
+ if (tool === undefined) return false
85
+
86
+ const needs = tool.needsApproval
87
+
88
+ if (needs === undefined || Predicate.isBoolean(needs)) return needs === true
89
+
90
+ return yield* Effect.gen(function* () {
91
+ const decided = needs(yield* decodeParams(tool, call.params), {
92
+ toolCallId: call.id,
93
+ messages: history
94
+ })
95
+
96
+ return Effect.isEffect(decided) ? yield* decided : decided
97
+ }).pipe(Effect.orElseSucceed(() => false))
98
+ })
99
+
100
+ // The toolkit decodes the params with the tool's schema before its handler sees them, and a call whose params don't
101
+ // fit fails with a validation error the model reads: the model's JSON is what `handle` takes.
102
+ const isParams = <T>(params: unknown): params is Tool.ParametersEncoded<T> => params !== undefined
103
+
104
+ // Every failure is the call's result, for the model to read. With `failureMode: "return"` the toolkit encodes the
105
+ // tool's own failure with its schema first.
106
+ /** @internal */
107
+ export const runNative = Effect.fnUntraced(function* <T extends Record<string, Tool.Any>>(
108
+ toolkit: Toolkit.WithHandler<T>,
109
+ call: AssistantEvent.ToolCallPart
110
+ ): Effect.fn.Return<CallResult, never, ToolServices<T>> {
111
+ const name: PropertyKey = call.name
112
+
113
+ if (!hasTool(toolkit, name)) return failed(`No tool named ${call.name}.`, "ToolNotFoundError")
114
+
115
+ const params: unknown = call.params
116
+
117
+ if (!isParams<T[keyof T]>(params)) {
118
+ return failed(`Invalid parameters for ${call.name}.`, "ToolParameterValidationError")
119
+ }
120
+
121
+ return yield* Effect.gen(function* () {
122
+ const results = yield* toolkit.handle<keyof T>(name, params, call.id)
123
+ const last = yield* Stream.runLast(Stream.filter(results, (result) => !result.preliminary))
124
+
125
+ return Option.match(last, {
126
+ onNone: () => failed(`${call.name} returned no result.`, "NoResult"),
127
+ onSome: (result): CallResult =>
128
+ result.isFailure
129
+ ? failure(toJson(result.encodedResult))
130
+ : { outcome: "success", result: toJson(result.encodedResult) }
131
+ })
132
+ }).pipe(
133
+ Effect.catch((error) =>
134
+ Effect.succeed(
135
+ AiError.isAiError(error) ? failed(error.reason.message, error.reason._tag) : failure(toJson(error))
136
+ )
137
+ ),
138
+ Effect.provideService(AssistantToolCall, { callId: call.id, tool: call.name })
139
+ )
140
+ })
141
+
142
+ /** @internal */
143
+ export const fromWithHandler = <T extends Record<string, Tool.Any>>(
144
+ toolkit: Toolkit.WithHandler<T>
145
+ ): Tools<ToolServices<T>> => ({
146
+ definitions: Object.values(toolkit.tools),
147
+ run: (call) => runNative(toolkit, call),
148
+ needsDecision: (call, history) => needsDecision(toolkit, call, history)
149
+ })
150
+
151
+ /** @internal */
152
+ export const fromToolkit = <T extends Record<string, Tool.Any>>(
153
+ toolkit: Toolkit.Toolkit<T>
154
+ ): Tools<ToolServices<T> | Tool.HandlersFor<T>> => ({
155
+ definitions: Object.values(toolkit.tools),
156
+ run: Effect.fnUntraced(function* (call) {
157
+ return yield* runNative(yield* toolkit, call)
158
+ }),
159
+ needsDecision: (call, history) => needsDecision(toolkit, call, history)
160
+ })
161
+
162
+ // The handlers use services of the loop they run in, so they're made for each call, with its context.
163
+ /** @internal */
164
+ export const withContext = <T extends Record<string, Tool.Any>, R>(
165
+ toolkit: Toolkit.Toolkit<T>,
166
+ handlers: (context: Context.Context<R>) => Toolkit.HandlersFrom<T>
167
+ ): Tools<R | ToolServices<T>> => ({
168
+ definitions: Object.values(toolkit.tools),
169
+ run: Effect.fnUntraced(function* (call) {
170
+ const context = yield* Effect.context<R>()
171
+ const resolved = yield* Effect.provideContext(toolkit, yield* toolkit.toHandlers(handlers(context)))
172
+
173
+ return yield* runNative(resolved, call)
174
+ }),
175
+ needsDecision: (call, history) => needsDecision(toolkit, call, history)
176
+ })
177
+
178
+ /** @internal */
179
+ export const fromToolkitOrEmpty = <T extends Record<string, Tool.Any>>(
180
+ toolkit: Toolkit.Toolkit<T> | undefined
181
+ ): Tools<ToolServices<T> | Tool.HandlersFor<T>> => (toolkit === undefined ? empty : fromToolkit(toolkit))
182
+
183
+ /** @internal */
184
+ export const empty: Tools = {
185
+ definitions: [],
186
+ run: (call) => Effect.succeed(failed(`No tool named ${call.name}.`, "ToolNotFoundError"))
187
+ }
188
+
189
+ /** @internal */
190
+ export const merge = <A, B>(self: Tools<A>, that: Tools<B>): Tools<A | B> => {
191
+ const inSelf = (call: AssistantEvent.ToolCallPart) => self.definitions.some((tool) => tool.name === call.name)
192
+
193
+ return {
194
+ definitions: [...self.definitions, ...that.definitions],
195
+ run: (call) => (inSelf(call) ? self.run(call) : that.run(call)),
196
+ needsDecision: (call, history) =>
197
+ inSelf(call) ? needsDecisionOf(self, call, history) : needsDecisionOf(that, call, history)
198
+ }
199
+ }
200
+
201
+ /** @internal */
202
+ export const needsDecisionOf = <R>(
203
+ tools: Tools<R>,
204
+ call: AssistantEvent.ToolCallPart,
205
+ history: ReadonlyArray<Prompt.Message>
206
+ ): Effect.Effect<boolean, never, R> =>
207
+ tools.needsDecision === undefined ? Effect.succeed(false) : tools.needsDecision(call, history)
208
+
209
+ // A turn's own services, for a loop the app writes.
210
+ /** @internal */
211
+ export const provide = <R>(tools: Tools<R>, context: Context.Context<R>): Tools => ({
212
+ definitions: tools.definitions,
213
+ run: (call) => Effect.provideContext(tools.run(call), context),
214
+ needsDecision: (call, history) => Effect.provideContext(needsDecisionOf(tools, call, history), context)
215
+ })
216
+
217
+ /** @internal */
218
+ export const duplicateNames = (tools: Tools<unknown>): ReadonlyArray<string> => {
219
+ const names = tools.definitions.map((tool) => tool.name)
220
+
221
+ return names.filter((name, i) => names.indexOf(name) !== i)
222
+ }
223
+
224
+ // The same tools are offered, so the prompt doesn't change.
225
+ /** @internal */
226
+ export const runOnly = <R>(tools: Tools<R>, names: ReadonlyArray<string>, message: string): Tools<R> => ({
227
+ definitions: tools.definitions,
228
+ run: (call) => (names.includes(call.name) ? tools.run(call) : Effect.succeed(failed(message, "NotNow"))),
229
+ needsDecision: (call, history) =>
230
+ names.includes(call.name) ? needsDecisionOf(tools, call, history) : Effect.succeed(false)
231
+ })
@@ -0,0 +1,210 @@
1
+ import * as Tool from "effect/ai/Tool"
2
+ import * as Toolkit from "effect/ai/Toolkit"
3
+ import * as Effect from "effect/Effect"
4
+ import * as Option from "effect/Option"
5
+ import * as Predicate from "effect/Predicate"
6
+ import * as Schema from "effect/Schema"
7
+ import * as AssistantEvent from "../AssistantEvent.ts"
8
+ import { AssistantToolCall } from "../AssistantToolCall.ts"
9
+ import * as BlockKit from "../BlockKit.ts"
10
+ import { CurrentChat } from "../CurrentChat.ts"
11
+ import * as Decision from "../Decision.ts"
12
+ import * as ToolFailure from "../ToolFailure.ts"
13
+ import * as Toolkits from "./toolkit.ts"
14
+ import * as Uuid from "./uuid.ts"
15
+
16
+ /** @internal */
17
+ export const waitToolName = "wait"
18
+
19
+ /** @internal */
20
+ export const sendUserMessageName = "send_user_message"
21
+
22
+ const Wait = Tool.make(waitToolName, {
23
+ description:
24
+ "End your turn and wait for the next message or update, when you have nothing more to do right now and no message to send (otherwise set wait on send_user_message).",
25
+ success: Schema.String
26
+ })
27
+
28
+ const description =
29
+ "Send the user a chat message. It's the only way they see anything you write: text outside it is private to you."
30
+
31
+ const WaitFlag = Schema.Boolean.annotate({
32
+ description:
33
+ "true when this is your last message for now: your turn ends with it, as with the wait tool. false when you keep working after it."
34
+ })
35
+
36
+ const endsWithMessage = Schema.decodeUnknownOption(Schema.Struct({ wait: Schema.Literal(true) }))
37
+
38
+ /** @internal */
39
+ export const isTurnEnding = (call: AssistantEvent.ToolCallPart): boolean =>
40
+ call.name === waitToolName || (call.name === sendUserMessageName && Option.isSome(endsWithMessage(call.params)))
41
+
42
+ // From the call: a call cut off by a crash runs again with the same id, and finds its message already sent.
43
+ const messageId = AssistantToolCall.useSync((call) => Uuid.fromKey(`message:${call.callId}`))
44
+
45
+ const append = Effect.fnUntraced(function* (message: AssistantEvent.AssistantMessage) {
46
+ const chat = yield* CurrentChat
47
+ const entries = yield* chat.entries
48
+
49
+ const sent = entries.some(
50
+ ({ event }) =>
51
+ AssistantEvent.isLibraryEvent(event) && Predicate.isTagged(event, "AssistantMessage") && event.id === message.id
52
+ )
53
+
54
+ if (!sent) yield* chat.append(message)
55
+ })
56
+
57
+ const PlainMessage = Tool.make(sendUserMessageName, {
58
+ description,
59
+ parameters: Schema.Struct({ text: Schema.String, wait: WaitFlag }),
60
+ success: Schema.String,
61
+ failure: ToolFailure.ToolFailure,
62
+ dependencies: [CurrentChat, AssistantToolCall]
63
+ })
64
+
65
+ const PlainTools = Toolkit.make(PlainMessage, Wait)
66
+
67
+ const plainHandlers = PlainTools.toHandlers({
68
+ wait: () => Effect.succeed("waiting"),
69
+ send_user_message: Effect.fnUntraced(function* ({ text }) {
70
+ yield* append(AssistantEvent.AssistantMessage.make({ id: yield* messageId, text }))
71
+
72
+ return "sent"
73
+ })
74
+ })
75
+
76
+ const plainTools = Effect.gen(function* () {
77
+ return yield* Effect.provideContext(PlainTools, yield* plainHandlers)
78
+ })
79
+
80
+ const decodeDecisionProps = Schema.decodeUnknownOption(Decision.Props)
81
+
82
+ const checkDecisions = Effect.fnUntraced(function* (ids: ReadonlyArray<string>) {
83
+ const states = Decision.fromEntries(yield* CurrentChat.use((chat) => chat.entries))
84
+ const notWaiting = ids.filter((id) => states.get(id)?.outcome !== undefined || !states.has(id))
85
+
86
+ if (notWaiting.length > 0) {
87
+ return yield* ToolFailure.fail(
88
+ `Not waiting for the user's decision: ${notWaiting.join(", ")}. Use the ids of decisions that wait for one.`,
89
+ { reason: "NotWaitingForDecision" }
90
+ )
91
+ }
92
+ })
93
+
94
+ // A decision block closes when the user decides on it, not because the model says so: the order it asks about would
95
+ // otherwise never be approved.
96
+ const checkCloses = Effect.fnUntraced(function* (ids: ReadonlyArray<string>) {
97
+ const entries = yield* CurrentChat.use((chat) => chat.entries)
98
+ const states = BlockKit.fromEntries(entries)
99
+
100
+ const waiting = ids.filter((id) =>
101
+ entries.some(
102
+ ({ event }) =>
103
+ AssistantEvent.isLibraryEvent(event) &&
104
+ Predicate.isTagged(event, "AssistantMessage") &&
105
+ event.id === id &&
106
+ event.block?.kind === Decision.kind &&
107
+ states.get(id)?.open === true
108
+ )
109
+ )
110
+
111
+ if (waiting.length > 0) {
112
+ return yield* ToolFailure.fail(
113
+ `${waiting.join(", ")} asks for a decision that still waits for the user: only their answer in the block closes it. Send the message again without ${waiting.length === 1 ? "that id" : "those ids"} in closes.`,
114
+ { reason: "DecisionWaiting" }
115
+ )
116
+ }
117
+ })
118
+
119
+ const blockTools = Effect.fnUntraced(function* (kit: BlockKit.BlockKit) {
120
+ const catalog = kit.blocks.map((block) => `- ${block.kind}: ${block.description}`).join("\n")
121
+ const kinds = kit.blocks.map((block) => block.kind)
122
+
123
+ const BlockMessage = Tool.make(sendUserMessageName, {
124
+ description: `${description}
125
+ You can add a block to show UI with the message, e.g. a few options to pick from or a yes/no, when that makes answering easier than typing. Plain text is fine too. Using it sends you an <answer>; the user may also just type their answer instead, and then you pass that message's id in closes.
126
+ Blocks:
127
+ ${catalog}`,
128
+ // Props are checked against their kind's schema by the handler; the model sees every kind's in the parameters.
129
+ parameters: Schema.Struct({
130
+ text: Schema.String,
131
+ wait: WaitFlag,
132
+ block: Schema.optional(
133
+ Schema.Struct({
134
+ kind: Schema.Literals(kinds),
135
+ props: Schema.Union(kit.blocks.map((block) => block.props))
136
+ })
137
+ ),
138
+ closes: Schema.optional(Schema.Array(Schema.String)).annotate({
139
+ description:
140
+ "Ids of your earlier messages whose block the user just answered by typing instead of using it. Those blocks close. Leave it out otherwise: a block stays open when the user talks about something else. A decision block never closes this way: only the user's answer in it decides."
141
+ })
142
+ }),
143
+ success: Schema.String,
144
+ failure: ToolFailure.ToolFailure,
145
+ dependencies: [CurrentChat, AssistantToolCall]
146
+ })
147
+
148
+ const BlockTools = Toolkit.make(BlockMessage, Wait)
149
+
150
+ const handlers = yield* BlockTools.toHandlers({
151
+ wait: () => Effect.succeed("waiting"),
152
+ send_user_message: Effect.fnUntraced(function* ({ text, block, closes }) {
153
+ if (text.trim() === "") {
154
+ return yield* ToolFailure.fail(
155
+ "The message has no text: the user sees only what you send here, and a block alone is an empty bubble. Send it again with the words they should read (a short lead-in for the block).",
156
+ { reason: "EmptyMessage" }
157
+ )
158
+ }
159
+
160
+ const id = yield* messageId
161
+ const closed = closes === undefined || closes.length === 0 ? {} : { closes }
162
+
163
+ if (closes !== undefined && closes.length > 0) yield* checkCloses(closes)
164
+
165
+ if (block === undefined) {
166
+ yield* append(AssistantEvent.AssistantMessage.make({ id, text, ...closed }))
167
+
168
+ return "sent"
169
+ }
170
+
171
+ const props = kit.byKind.get(block.kind)?.props
172
+
173
+ if (props === undefined || Option.isNone(Schema.decodeUnknownOption(props)(block.props))) {
174
+ return yield* ToolFailure.fail(`Those props don't fit a ${block.kind} block.`)
175
+ }
176
+
177
+ if (block.kind === Decision.kind) {
178
+ yield* checkDecisions(
179
+ Option.match(decodeDecisionProps(block.props), {
180
+ onNone: () => [],
181
+ onSome: (decoded) => decoded.decisionIds
182
+ })
183
+ )
184
+ }
185
+
186
+ yield* append(
187
+ AssistantEvent.AssistantMessage.make({
188
+ id,
189
+ text,
190
+ block: AssistantEvent.MessageBlock.make(block),
191
+ ...closed
192
+ })
193
+ )
194
+
195
+ return `sent as message ${id}`
196
+ })
197
+ })
198
+
199
+ return yield* Effect.provideContext(BlockTools, handlers)
200
+ })
201
+
202
+ /** @internal */
203
+ export const makeMessaging: (kit: BlockKit.BlockKit | undefined) => Effect.Effect<Toolkits.Tools<CurrentChat>> =
204
+ Effect.fnUntraced(function* (kit) {
205
+ if (kit === undefined || kit.blocks.length === 0) {
206
+ return Toolkits.fromWithHandler(yield* plainTools)
207
+ }
208
+
209
+ return Toolkits.fromWithHandler(yield* blockTools(kit))
210
+ })