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,344 @@
1
+ import type * as AiError from "effect/ai/AiError"
2
+ import * as LanguageModel from "effect/ai/LanguageModel"
3
+ import * as Model from "effect/ai/Model"
4
+ import * as Prompt from "effect/ai/Prompt"
5
+ import type * as Response from "effect/ai/Response"
6
+ import type * as Tool from "effect/ai/Tool"
7
+ import type * as Toolkit from "effect/ai/Toolkit"
8
+ import * as Clock from "effect/Clock"
9
+ import * as Context from "effect/Context"
10
+ import * as Data from "effect/Data"
11
+ import * as Effect from "effect/Effect"
12
+ import * as Option from "effect/Option"
13
+ import * as Predicate from "effect/Predicate"
14
+ import * as Schema from "effect/Schema"
15
+ import * as Stream from "effect/Stream"
16
+ import * as AssistantEvent from "../AssistantEvent.ts"
17
+ import * as DecisionId from "../DecisionId.ts"
18
+ import type * as Instructions from "../Instructions.ts"
19
+ import * as Decisions from "./decisions.ts"
20
+ import * as History from "./history.ts"
21
+ import * as Telemetry from "./telemetry.ts"
22
+ import * as Toolkits from "./toolkit.ts"
23
+ import { isTurnEnding } from "./tools.ts"
24
+
25
+ /** @internal */
26
+ export type Event =
27
+ | AssistantEvent.ModelOutput
28
+ | AssistantEvent.ToolStarted
29
+ | AssistantEvent.ToolResult
30
+ | AssistantEvent.DecisionRequested
31
+
32
+ // The assistant's own tools with the turn's services, so an app's own loop in a turn can message the user. A
33
+ // specialist's run has none.
34
+ /** @internal */
35
+ export const TurnTools = Context.Reference<Option.Option<Toolkits.Tools>>("effect-assistant/TurnTools", {
36
+ defaultValue: Option.none
37
+ })
38
+
39
+ /** @internal */
40
+ export interface ChatHistory {
41
+ readonly messages: ReadonlyArray<Prompt.Message>
42
+ readonly throughSeq: number
43
+ }
44
+
45
+ const readHistory = (read: ReadonlyArray<Prompt.Message> | ChatHistory) =>
46
+ "messages" in read
47
+ ? { messages: read.messages, throughSeq: read.throughSeq }
48
+ : { messages: read, throughSeq: undefined }
49
+
50
+ // `AssistantLoop` takes native toolkits; turns and specialist runs come here with their tools already merged.
51
+ /** @internal */
52
+ export interface StepOptions<R = never> {
53
+ readonly instructions: string | Instructions.Resolved
54
+ readonly toolkit?: Toolkits.Tools<R> | undefined
55
+ readonly history: Effect.Effect<ReadonlyArray<Prompt.Message> | ChatHistory>
56
+ readonly toolChoice?: LanguageModel.ToolChoice<string> | undefined
57
+ }
58
+
59
+ /** @internal */
60
+ export interface Options<R = never> extends StepOptions<R> {
61
+ readonly maxSteps?: number | undefined
62
+ }
63
+
64
+ // Its member docs show through `AssistantLoop.StepResult`.
65
+ /** @internal */
66
+ export interface StepResult {
67
+ /** The model's output; unset when the step only ran calls a crash cut off. */
68
+ readonly output: AssistantEvent.ModelOutput | undefined
69
+ /**
70
+ * Whether the model is done: it made no tool calls, or ended its turn (`wait`, or a message
71
+ * with `wait: true`) and none of the step's calls failed.
72
+ */
73
+ readonly done: boolean
74
+ /**
75
+ * The history's length with this step's output and results: a longer one got something new meanwhile.
76
+ */
77
+ readonly historyLength: number
78
+ }
79
+
80
+ // The loop runs the calls itself, after storing each as started, so the model call runs none.
81
+ const requestToolkit = (definitions: ReadonlyArray<Tool.Any>): Toolkit.WithHandler<Record<string, Tool.Any>> => ({
82
+ // The loop decides which calls wait for the user's decision; the model call would ask for approvals of its own.
83
+ tools: Object.fromEntries(
84
+ definitions.map((tool) => [tool.name, tool.needsApproval === undefined ? tool : tool.setNeedsApproval(false)])
85
+ ),
86
+ handle: () => Effect.succeed(Stream.empty)
87
+ })
88
+
89
+ // Tool call resolution stays on with a toolkit that runs nothing: with it off, a call whose params don't fit its
90
+ // tool's schema fails the whole model call, and here the model reads why instead.
91
+ const generate = (
92
+ prompt: Prompt.Prompt,
93
+ definitions: ReadonlyArray<Tool.Any>,
94
+ toolChoice: LanguageModel.ToolChoice<string> | undefined
95
+ ): Effect.Effect<
96
+ LanguageModel.GenerateTextResponse<Record<string, Tool.Any>, "opaque">,
97
+ AiError.AiError,
98
+ LanguageModel.LanguageModel
99
+ > => LanguageModel.generateText({ prompt, toolkit: requestToolkit(definitions), toolChoice })
100
+
101
+ const decodePart = Schema.decodeUnknownOption(AssistantEvent.ModelPart)
102
+
103
+ const modelParts = (content: ReadonlyArray<Response.AllParts<Record<string, Tool.Any>>>) =>
104
+ content.flatMap((part) =>
105
+ Option.toArray(decodePart(part)).filter((decoded) => !(decoded.type === "tool-call" && decoded.providerExecuted))
106
+ )
107
+
108
+ const toolCalls = (parts: ReadonlyArray<AssistantEvent.ModelPart>): ReadonlyArray<AssistantEvent.ToolCallPart> =>
109
+ parts.flatMap((part) => (part.type === "tool-call" ? [part] : []))
110
+
111
+ const runCall = Effect.fnUntraced(function* <R>(tools: Toolkits.Tools<R>, call: AssistantEvent.ToolCallPart) {
112
+ const result = yield* Effect.gen(function* () {
113
+ const result = yield* tools.run(call)
114
+
115
+ yield* Telemetry.toolOutput(result.result)
116
+
117
+ return result
118
+ }).pipe(Telemetry.toolSpan(call))
119
+
120
+ return AssistantEvent.ToolResult.make({ callId: call.id, tool: call.name, ...result })
121
+ })
122
+
123
+ interface Pending {
124
+ readonly call: AssistantEvent.ToolCallPart
125
+ readonly approved: boolean
126
+ }
127
+
128
+ // Only a turn parks: a specialist's call fails instead.
129
+ const park = Effect.fnUntraced(
130
+ function* (call: AssistantEvent.ToolCallPart) {
131
+ if (Option.isNone(yield* Decisions.TurnDecisions)) {
132
+ return AssistantEvent.ToolResult.make({
133
+ callId: call.id,
134
+ tool: call.name,
135
+ ...Toolkits.failed(
136
+ `${call.name} needs the user's decision, and only the assistant can ask them. Don't run it: say in your answer what needs deciding, with the details.`,
137
+ "NeedsDecision"
138
+ )
139
+ })
140
+ }
141
+
142
+ return AssistantEvent.DecisionRequested.make({
143
+ id: DecisionId.fromCallId(call.id),
144
+ origin: AssistantEvent.CallOrigin.make({ callId: call.id, tool: call.name, params: call.params })
145
+ })
146
+ },
147
+ (effect, call) => effect.pipe(Telemetry.toolSpan(call))
148
+ )
149
+
150
+ // Every `ToolStarted` is stored before any call runs. `then` is told whether the model has to read the results: one
151
+ // failed, or waits for the user's decision.
152
+ const runCalls = <R, S, E, R2>(
153
+ tools: Toolkits.Tools<R>,
154
+ pending: ReadonlyArray<Pending>,
155
+ history: ReadonlyArray<Prompt.Message>,
156
+ then: (reread: boolean) => Stream.Stream<Step<never, S>, E, R2>
157
+ ): Stream.Stream<Step<Event, S>, E, R | R2> =>
158
+ Stream.unwrap(
159
+ Effect.gen(function* () {
160
+ const parks = yield* Effect.forEach(pending, ({ call, approved }) =>
161
+ approved ? Effect.succeed(false) : Toolkits.needsDecisionOf(tools, call, history)
162
+ )
163
+
164
+ const started = pending.flatMap(({ call }, i) =>
165
+ parks[i] ? [] : [value(AssistantEvent.ToolStarted.make({ callId: call.id, tool: call.name }))]
166
+ )
167
+
168
+ const results = Effect.forEach(pending, ({ call }, i) => (parks[i] ? park(call) : runCall(tools, call)), {
169
+ concurrency: "unbounded"
170
+ })
171
+
172
+ const reread = (done: ReadonlyArray<Event>) =>
173
+ done.some((event) => Predicate.isTagged(event, "DecisionRequested") || isFailure(event))
174
+
175
+ return Stream.fromIterable(started).pipe(
176
+ Stream.concat(
177
+ Stream.unwrap(
178
+ Effect.gen(function* () {
179
+ const done = yield* results
180
+
181
+ return Stream.fromIterable(done.map(value)).pipe(Stream.concat(then(reread(done))))
182
+ })
183
+ )
184
+ )
185
+ )
186
+ })
187
+ )
188
+
189
+ const isFailure = (event: Event) => Predicate.isTagged(event, "ToolResult") && event.outcome === "failure"
190
+
191
+ /** @internal */
192
+ export type Step<A, S> = Data.TaggedEnum<{
193
+ Value: { readonly value: A }
194
+ Next: { readonly state: S }
195
+ Stop: Record<never, never>
196
+ }>
197
+
198
+ interface StepDefinition extends Data.TaggedEnum.WithGenerics<2> {
199
+ readonly taggedEnum: Step<this["A"], this["B"]>
200
+ }
201
+
202
+ const Steps = Data.taggedEnum<StepDefinition>()
203
+
204
+ /** @internal */
205
+ export const value = <A>(value: A): Step<A, never> => Steps.Value({ value })
206
+
207
+ /** @internal */
208
+ export const next = <S>(state: S): Stream.Stream<Step<never, S>> => Stream.succeed(Steps.Next({ state }))
209
+
210
+ /** @internal */
211
+ // oxlint-disable-next-line no-unnecessary-type-parameters -- `stop<number>()` types a loop's state where inference can't.
212
+ export const stop = <S = never>(): Stream.Stream<Step<never, S>> => Stream.succeed(Steps.Stop())
213
+
214
+ const isValue = <A, S>(step: Step<A, S>): step is Data.TaggedEnum.Value<Step<A, S>, "Value"> =>
215
+ Predicate.isTagged(step, "Value")
216
+
217
+ // A body that ends without `next` or `stop` stops too.
218
+ /** @internal */
219
+ export const loop = <S, A, E, R>(
220
+ initial: S,
221
+ body: (state: S) => Stream.Stream<Step<A, S>, E, R>
222
+ ): Stream.Stream<A, E, R> => {
223
+ const go = (state: S): Stream.Stream<A, E, R> =>
224
+ Stream.suspend(() => {
225
+ let after: Option.Option<S> = Option.none()
226
+
227
+ return body(state).pipe(
228
+ Stream.tap((step) =>
229
+ Effect.sync(() => (after = Predicate.isTagged(step, "Next") ? Option.some(step.state) : after))
230
+ ),
231
+ Stream.filter(isValue),
232
+ Stream.map((step) => step.value),
233
+ Stream.concat(Stream.suspend(() => Option.match(after, { onNone: () => Stream.empty, onSome: go })))
234
+ )
235
+ })
236
+
237
+ return go(initial)
238
+ }
239
+
240
+ /** @internal */
241
+ export const step = <R, S, E2 = never, R2 = never>(
242
+ options: StepOptions<R>,
243
+ then: (result: StepResult) => Stream.Stream<Step<never, S>, E2, R2>
244
+ ): Stream.Stream<Step<Event, S>, AiError.AiError | E2, LanguageModel.LanguageModel | R | R2> => {
245
+ const toolkit = options.toolkit ?? Toolkits.empty
246
+ const instructions = Predicate.isString(options.instructions) ? { text: options.instructions } : options.instructions
247
+
248
+ const after = (result: StepResult) => Stream.suspend(() => then(result))
249
+
250
+ return Stream.unwrap(
251
+ Effect.gen(function* () {
252
+ const { messages: history, throughSeq } = readHistory(yield* options.history)
253
+ // Calls a crash cut off, with no result: they run again, with the same ids, before the model goes on.
254
+ const cutOff = History.unansweredCalls(history)
255
+
256
+ // Calls the user approved: they run as the model made them, before the model goes on. Not in a loop without them.
257
+ const decisions = yield* Decisions.TurnDecisions
258
+
259
+ const approved = Option.isNone(decisions)
260
+ ? []
261
+ : (yield* decisions.value.approved).filter((call) =>
262
+ toolkit.definitions.some((tool) => tool.name === call.name)
263
+ )
264
+
265
+ if (cutOff.length > 0 || approved.length > 0) {
266
+ const pending = [
267
+ ...cutOff.map((call) => ({ call, approved: false })),
268
+ ...approved.map((call) => ({ call, approved: true }))
269
+ ]
270
+
271
+ return runCalls(toolkit, pending, history, () =>
272
+ after({ output: undefined, done: false, historyLength: history.length + pending.length })
273
+ )
274
+ }
275
+
276
+ const prompt = Prompt.fromMessages([Prompt.systemMessage({ content: instructions.text }), ...history])
277
+ const name = yield* Effect.serviceOption(Model.ModelName)
278
+ const started = yield* Clock.currentTimeMillis
279
+
280
+ const response = yield* generate(prompt, toolkit.definitions, options.toolChoice).pipe(
281
+ Telemetry.chatSpan(prompt, instructions.attributes)
282
+ )
283
+
284
+ const metadata = response.content.find((part) => part.type === "response-metadata")
285
+
286
+ const output = AssistantEvent.ModelOutput.make({
287
+ parts: modelParts(response.content),
288
+ seenThroughSeq: throughSeq,
289
+ model: metadata?.modelId ?? Option.getOrUndefined(name),
290
+ responseId: metadata?.id,
291
+ usage: response.usage,
292
+ durationMillis: (yield* Clock.currentTimeMillis) - started
293
+ })
294
+
295
+ const calls = toolCalls(output.parts)
296
+ const historyLength = history.length + Prompt.fromResponseParts(output.parts).content.length + calls.length
297
+
298
+ // A failed call doesn't end the turn: a message that failed never reached the user, so the model reads why. Nor
299
+ // does a parked one: the model asks the user for the decision it waits for.
300
+ return Stream.make(value(output)).pipe(
301
+ Stream.concat(
302
+ runCalls(
303
+ toolkit,
304
+ calls.map((call) => ({ call, approved: false })),
305
+ history,
306
+ (reread) =>
307
+ after({
308
+ output,
309
+ done: calls.length === 0 || (!reread && calls.some(isTurnEnding)),
310
+ historyLength
311
+ })
312
+ )
313
+ )
314
+ )
315
+ })
316
+ )
317
+ }
318
+
319
+ /** @internal */
320
+ export const run = <R = never>(
321
+ options: Options<R>
322
+ ): Stream.Stream<Event, AiError.AiError, LanguageModel.LanguageModel | R> => {
323
+ const maxSteps = options.maxSteps ?? 16
324
+
325
+ // Done: stop, unless something arrived while the model was working; then it reads that first.
326
+ const afterDone = (steps: number, historyLength: number) =>
327
+ Stream.unwrap(
328
+ Effect.gen(function* () {
329
+ const read = yield* options.history
330
+
331
+ return readHistory(read).messages.length > historyLength ? next(steps) : stop<number>()
332
+ })
333
+ )
334
+
335
+ return loop(0, (steps: number) =>
336
+ step(options, (result) => {
337
+ const taken = result.output === undefined ? steps : steps + 1
338
+
339
+ if (taken >= maxSteps) return stop<number>()
340
+
341
+ return result.done ? afterDone(taken, result.historyLength) : next(taken)
342
+ })
343
+ )
344
+ }
@@ -0,0 +1,25 @@
1
+ import { flow } from "effect/Function"
2
+ import * as Option from "effect/Option"
3
+ import * as Schema from "effect/Schema"
4
+
5
+ const JsonText = Schema.fromJsonString(Schema.Unknown)
6
+
7
+ // A value with no JSON form (`undefined`, a cycle) is empty text rather than a defect.
8
+ /** @internal */
9
+ export const toJsonText = flow(
10
+ Schema.encodeUnknownOption(JsonText),
11
+ Option.getOrElse(() => "")
12
+ )
13
+
14
+ /** @internal */
15
+ export const isJsonText = (text: string): boolean => Option.isSome(Schema.decodeOption(JsonText)(text))
16
+
17
+ const decodeJsonText = Schema.decodeOption(Schema.fromJsonString(Schema.Json))
18
+
19
+ // As `JSON.stringify` writes it (a `Date` as its ISO string, a class as its fields), or `null` when it has none.
20
+ /** @internal */
21
+ export const toJson = flow(
22
+ Schema.encodeUnknownOption(JsonText),
23
+ Option.flatMap(decodeJsonText),
24
+ Option.getOrElse((): Schema.Json => null)
25
+ )
@@ -0,0 +1,37 @@
1
+ import * as Tool from "effect/ai/Tool"
2
+ import * as Toolkit from "effect/ai/Toolkit"
3
+ import type * as Context from "effect/Context"
4
+ import * as Effect from "effect/Effect"
5
+ import * as Schema from "effect/Schema"
6
+ import * as Instructions from "../Instructions.ts"
7
+ import type { Skill } from "../Skill.ts"
8
+ import * as ToolFailure from "../ToolFailure.ts"
9
+ import * as Toolkits from "./toolkit.ts"
10
+
11
+ /** @internal */
12
+ export const toTools = <R>(skills: ReadonlyArray<Skill<R>>): Toolkits.Tools<R> => {
13
+ if (skills.length === 0) return Toolkits.empty
14
+
15
+ const byName = new Map(skills.map((skill) => [skill.name, skill]))
16
+
17
+ const LoadSkill = Tool.make("load_skill", {
18
+ description:
19
+ "Load a skill's instructions by name, in the same step as your first lookups (not in a step of its own). Follow them for the rest of the task.",
20
+ parameters: Schema.Struct({ name: Schema.Literals(skills.map((skill) => skill.name)) }),
21
+ success: Schema.String,
22
+ failure: ToolFailure.ToolFailure
23
+ })
24
+
25
+ // A skill's instructions resolve with the services of the loop that loads it.
26
+ return Toolkits.withContext(Toolkit.make(LoadSkill), (context: Context.Context<R>) => ({
27
+ load_skill: Effect.fnUntraced(function* ({ name }) {
28
+ const skill = byName.get(name)
29
+
30
+ if (skill === undefined) return yield* ToolFailure.fail(`No skill named ${name}.`)
31
+
32
+ const resolved = yield* Instructions.resolve(skill.instructions).pipe(Effect.provideContext(context))
33
+
34
+ return resolved.text
35
+ })
36
+ }))
37
+ }