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,207 @@
1
+ /**
2
+ * Runs the agent loop, which calls the model, runs its tool calls, and repeats.
3
+ *
4
+ * Most handlers use `turn.loop` or `run.loop`. This module is for a loop of your own: {@link run}
5
+ * runs the loop until the model is done, and {@link step} is one step, as a body for
6
+ * {@link loop}.
7
+ *
8
+ * Tools are a native toolkit (`effect/ai/Toolkit`) whose handlers come from the handler's layer.
9
+ * In a turn, the assistant's own tools (`send_user_message`, `wait`, and the specialist tools)
10
+ * are added to them.
11
+ *
12
+ * @since 0.1.0
13
+ */
14
+ import type * as AiError from "effect/ai/AiError"
15
+ import type * as LanguageModel from "effect/ai/LanguageModel"
16
+ import type * as Prompt from "effect/ai/Prompt"
17
+ import type * as Tool from "effect/ai/Tool"
18
+ import type * as Toolkit from "effect/ai/Toolkit"
19
+ import * as Effect from "effect/Effect"
20
+ import * as Option from "effect/Option"
21
+ import * as Stream from "effect/Stream"
22
+ import type * as Instructions from "./Instructions.ts"
23
+ import * as Loop from "./internal/loop.ts"
24
+ import * as Toolkits from "./internal/toolkit.ts"
25
+
26
+ /**
27
+ * Represents what the loop emits.
28
+ *
29
+ * For each model call, the loop emits the call's output. Then, for each of its tool calls, it
30
+ * emits a `ToolStarted` before the call runs and the call's result, or a `DecisionRequested` for
31
+ * a call that waits for the user's decision.
32
+ *
33
+ * @category models
34
+ * @since 0.1.0
35
+ */
36
+ export type Event = Loop.Event
37
+
38
+ /**
39
+ * Represents a chat's history as the model reads it.
40
+ *
41
+ * It holds the messages, and the last `seq` of the chat they cover.
42
+ *
43
+ * @category models
44
+ * @since 0.1.0
45
+ */
46
+ export type ChatHistory = Loop.ChatHistory
47
+
48
+ /**
49
+ * Represents how a step went, for deciding what comes next.
50
+ *
51
+ * @category models
52
+ * @since 0.1.0
53
+ */
54
+ export type StepResult = Loop.StepResult
55
+
56
+ /**
57
+ * Represents what a loop body emits, which is either a value or what to do next.
58
+ *
59
+ * @category models
60
+ * @since 0.1.0
61
+ */
62
+ export type Step<A, S> = Loop.Step<A, S>
63
+
64
+ /**
65
+ * Emits a value from a loop body.
66
+ *
67
+ * @category constructors
68
+ * @since 0.1.0
69
+ */
70
+ export const value = Loop.value
71
+
72
+ /**
73
+ * Ends a loop body's step and continues the loop with the given state.
74
+ *
75
+ * @category constructors
76
+ * @since 0.1.0
77
+ */
78
+ export const next = Loop.next
79
+
80
+ /**
81
+ * Ends the loop.
82
+ *
83
+ * @category constructors
84
+ * @since 0.1.0
85
+ */
86
+ export const stop = Loop.stop
87
+
88
+ /**
89
+ * Runs a loop body from an initial state.
90
+ *
91
+ * It emits the body's values, and runs the body again with the state it passes to `next`, until
92
+ * the body calls `stop`.
93
+ *
94
+ * @category constructors
95
+ * @since 0.1.0
96
+ */
97
+ export const loop = Loop.loop
98
+
99
+ type NoTools = Record<never, never>
100
+
101
+ /**
102
+ * Extracts the services a toolkit's tools need when they run, which are their handlers and the
103
+ * services those handlers use.
104
+ *
105
+ * @category utility types
106
+ * @since 0.1.0
107
+ */
108
+ export type ToolkitServices<T extends Record<string, Tool.Any>> = Tool.HandlersFor<T> | Toolkits.ToolServices<T>
109
+
110
+ /**
111
+ * Represents the options for {@link step}.
112
+ *
113
+ * @category models
114
+ * @since 0.1.0
115
+ */
116
+ export interface StepOptions<T extends Record<string, Tool.Any> = NoTools> {
117
+ /** The full system prompt, like `turn.instructions(text)`, optionally with span attributes. */
118
+ readonly instructions: string | Instructions.Resolved
119
+ /**
120
+ * The tools the model can call, in addition to the assistant's own tools in a turn. Their
121
+ * handlers come from the layer.
122
+ */
123
+ readonly toolkit?: Toolkit.Toolkit<T> | undefined
124
+ /**
125
+ * The current history, read at the start of the step. It is either messages, or a chat's
126
+ * history (`turn.history()`), whose `throughSeq` the step's `ModelOutput` records as how far
127
+ * the model had read.
128
+ */
129
+ readonly history: Effect.Effect<ReadonlyArray<Prompt.Message> | ChatHistory>
130
+ /**
131
+ * Defaults to `"auto"`, where a step without a tool call ends the loop. Forcing a call
132
+ * (`"required"`, or a tool by name) is refused by newer models, and with extended thinking.
133
+ */
134
+ readonly toolChoice?: LanguageModel.ToolChoice<string> | undefined
135
+ }
136
+
137
+ /**
138
+ * Represents the options for {@link run}.
139
+ *
140
+ * @category models
141
+ * @since 0.1.0
142
+ */
143
+ export interface Options<T extends Record<string, Tool.Any> = NoTools> extends StepOptions<T> {
144
+ /** The model calls allowed. Defaults to 16. */
145
+ readonly maxSteps?: number | undefined
146
+ }
147
+
148
+ const toolsOf = <T extends Record<string, Tool.Any>>(toolkit: Toolkit.Toolkit<T> | undefined) =>
149
+ Effect.map(Loop.TurnTools, (turn) => {
150
+ const own = toolkit === undefined ? Toolkits.empty : Toolkits.fromToolkit(toolkit)
151
+
152
+ return Option.match(turn, { onNone: () => own, onSome: (builtins) => Toolkits.merge(builtins, own) })
153
+ })
154
+
155
+ /**
156
+ * Runs one step of the agent loop, as a body for {@link loop}.
157
+ *
158
+ * **Details**
159
+ *
160
+ * The step calls the model with the current history and emits its `ModelOutput`. It then runs
161
+ * the output's tool calls and emits a `ToolStarted` and a `ToolResult` for each, or a
162
+ * `DecisionRequested` for a call that waits for the user's decision. `then` receives the step's
163
+ * result and decides what comes next, with `AssistantLoop.next(state)` or `AssistantLoop.stop()`.
164
+ *
165
+ * @category constructors
166
+ * @since 0.1.0
167
+ */
168
+ export const step = <T extends Record<string, Tool.Any> = NoTools, S = never, E2 = never, R2 = never>(
169
+ options: StepOptions<T>,
170
+ then: (result: StepResult) => Stream.Stream<Step<never, S>, E2, R2>
171
+ ): Stream.Stream<Step<Event, S>, AiError.AiError | E2, LanguageModel.LanguageModel | ToolkitServices<T> | R2> =>
172
+ Stream.unwrap(Effect.map(toolsOf(options.toolkit), (toolkit) => Loop.step({ ...options, toolkit }, then)))
173
+
174
+ /**
175
+ * Runs the agent loop as a stream.
176
+ *
177
+ * **Details**
178
+ *
179
+ * It runs {@link step} after step until the model is done and nothing new arrived while it
180
+ * worked, or until it has made `maxSteps` model calls.
181
+ *
182
+ * **Example** (A turn's loop with a step limit)
183
+ *
184
+ * ```ts
185
+ * import { AssistantLoop } from "effect-assistant"
186
+ * import * as Layer from "effect/Layer"
187
+ * import * as Stream from "effect/Stream"
188
+ *
189
+ * const instructions = "You help a small marketing team."
190
+ *
191
+ * const MyAssistantLive = MyAssistant.toLayer((turn) =>
192
+ * AssistantLoop.run({
193
+ * instructions: turn.instructions(instructions),
194
+ * toolkit: MyTools, // the assistant's own tools are added to them
195
+ * history: turn.history(),
196
+ * maxSteps: 12
197
+ * }).pipe(Stream.concat(turn.replyIfOwed({ instructions })))
198
+ * ).pipe(Layer.provide(MyToolsLive))
199
+ * ```
200
+ *
201
+ * @category constructors
202
+ * @since 0.1.0
203
+ */
204
+ export const run = <T extends Record<string, Tool.Any> = NoTools>(
205
+ options: Options<T>
206
+ ): Stream.Stream<Event, AiError.AiError, LanguageModel.LanguageModel | ToolkitServices<T>> =>
207
+ Stream.unwrap(Effect.map(toolsOf(options.toolkit), (toolkit) => Loop.run({ ...options, toolkit })))
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Gives a tool the call it runs for.
3
+ *
4
+ * The {@link AssistantToolCall} service holds the call's id and the tool's name, and
5
+ * {@link idempotencyKey} derives a key for anything the call does outside the chat.
6
+ *
7
+ * @since 0.1.0
8
+ */
9
+ import * as Context from "effect/Context"
10
+ import type * as Effect from "effect/Effect"
11
+
12
+ /**
13
+ * Service that describes the tool call a tool runs for.
14
+ *
15
+ * The agent loop provides it to every call.
16
+ *
17
+ * @category services
18
+ * @since 0.1.0
19
+ */
20
+ export class AssistantToolCall extends Context.Service<
21
+ AssistantToolCall,
22
+ {
23
+ /** The call's id, as the model wrote it. It stays the same when a crash runs the call again. */
24
+ readonly callId: string
25
+ /** The tool's name. */
26
+ readonly tool: string
27
+ }
28
+ >()("effect-assistant/AssistantToolCall") {}
29
+
30
+ /**
31
+ * Gets an idempotency key for what the current tool call does outside the chat.
32
+ *
33
+ * The key is the same every time the call runs, and different for every other call.
34
+ *
35
+ * @category accessors
36
+ * @since 0.1.0
37
+ */
38
+ export const idempotencyKey: Effect.Effect<string, never, AssistantToolCall> = AssistantToolCall.useSync(
39
+ (call) => `tool-call:${call.callId}`
40
+ )
package/src/Block.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Defines UI blocks that an assistant can put in a message.
3
+ *
4
+ * A block is a bit of UI for when a structured answer is easier than typing, such as options to
5
+ * pick from, approve and deny buttons, or a small input. The assistant fills in its `props`, and
6
+ * using the block sends back a value typed by its `answer` schema.
7
+ *
8
+ * @since 0.1.0
9
+ */
10
+ import type * as Schema from "effect/Schema"
11
+
12
+ /**
13
+ * Schema that a block's props or answer can use.
14
+ *
15
+ * It is any codec that needs no services, since the client decodes and encodes with it too.
16
+ *
17
+ * @category utility types
18
+ * @since 0.1.0
19
+ */
20
+ export type Codec = Schema.Codec<any, any>
21
+
22
+ /**
23
+ * Represents a kind of UI block.
24
+ *
25
+ * @category models
26
+ * @since 0.1.0
27
+ */
28
+ export interface Block<Kind extends string = string, Props extends Codec = Codec, Answer extends Codec = Codec> {
29
+ readonly kind: Kind
30
+ /** When to use the block. The assistant sees this. */
31
+ readonly description: string
32
+ /** The props the assistant fills in. */
33
+ readonly props: Props
34
+ /** What using the block sends back, or `undefined` for a display-only card. */
35
+ readonly answer: Answer | undefined
36
+ }
37
+
38
+ /**
39
+ * Represents any block, for generic constraints.
40
+ *
41
+ * @category utility types
42
+ * @since 0.1.0
43
+ */
44
+ export type Any = Block
45
+
46
+ /**
47
+ * Creates a block.
48
+ *
49
+ * **Details**
50
+ *
51
+ * A block without an `answer` schema is a display-only card. Put the block in the assistant's
52
+ * `BlockKit` so the assistant can use it.
53
+ *
54
+ * **Example** (Defining a block with options to pick from)
55
+ *
56
+ * ```ts
57
+ * import { Assistant, Block, BlockKit } from "effect-assistant"
58
+ * import * as Schema from "effect/Schema"
59
+ *
60
+ * const PickOne = Block.make("pick_one", {
61
+ * description: "Options to pick from, when the user chooses between a few.",
62
+ * props: Schema.Struct({ options: Schema.Array(Schema.String) }),
63
+ * answer: Schema.Struct({ picked: Schema.String })
64
+ * })
65
+ *
66
+ * export const MyAssistant = Assistant.make("my-assistant", { blocks: BlockKit.make([PickOne]) })
67
+ * ```
68
+ *
69
+ * @category constructors
70
+ * @since 0.1.0
71
+ */
72
+ export const make = <const Kind extends string, Props extends Codec, Answer extends Codec = Schema.Never>(
73
+ kind: Kind,
74
+ options: {
75
+ readonly description: string
76
+ readonly props: Props
77
+ readonly answer?: Answer | undefined
78
+ }
79
+ ): Block<Kind, Props, Answer> => ({
80
+ kind,
81
+ description: options.description,
82
+ props: options.props,
83
+ answer: options.answer
84
+ })
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Groups the blocks an assistant may put in its messages, and renders them on the client.
3
+ *
4
+ * A kit is created with {@link make} and passed to `Assistant.make`. {@link fromEntries} computes
5
+ * the state of every message's block from a chat's log, {@link closeStale} closes the blocks the
6
+ * user moved on from, and {@link toClient} creates a client-side renderer with one function per
7
+ * block kind.
8
+ *
9
+ * @since 0.1.0
10
+ */
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 AssistantEvent from "./AssistantEvent.ts"
16
+ import type * as Block from "./Block.ts"
17
+ import type * as ChatView from "./ChatView.ts"
18
+ import { CurrentChat } from "./CurrentChat.ts"
19
+ import * as Decision from "./Decision.ts"
20
+ import * as InputId from "./InputId.ts"
21
+
22
+ /**
23
+ * Represents a catalog of blocks, with a lookup by kind.
24
+ *
25
+ * @category models
26
+ * @since 0.1.0
27
+ */
28
+ export interface BlockKit<Blocks extends Block.Any = Block.Any> {
29
+ readonly blocks: ReadonlyArray<Blocks>
30
+ readonly byKind: ReadonlyMap<string, Blocks>
31
+ }
32
+
33
+ /**
34
+ * Creates a kit from blocks.
35
+ *
36
+ * @category constructors
37
+ * @since 0.1.0
38
+ */
39
+ export const make = <const Blocks extends ReadonlyArray<Block.Any>>(blocks: Blocks): BlockKit<Blocks[number]> => ({
40
+ blocks,
41
+ byKind: new Map(blocks.map((block) => [block.kind, block]))
42
+ })
43
+
44
+ type KindOf<B> = B extends { readonly kind: infer K extends string } ? K : never
45
+
46
+ type BlockOf<Kit extends BlockKit, K> = Extract<Kit["blocks"][number], { readonly kind: K }>
47
+
48
+ type PropsOf<B> = B extends { readonly props: Schema.Top } ? B["props"]["Type"] : never
49
+
50
+ type AnswerOf<B> = B extends { readonly answer: infer A } ? (A extends Schema.Top ? A["Type"] : never) : never
51
+
52
+ /**
53
+ * Represents the state of a message's block.
54
+ *
55
+ * @category models
56
+ * @since 0.1.0
57
+ */
58
+ export interface State {
59
+ readonly open: boolean
60
+ /** The user's answer, if they used the block. */
61
+ readonly answer: AssistantEvent.BlockAnswer | undefined
62
+ }
63
+
64
+ /**
65
+ * Returns the block states after one more event.
66
+ *
67
+ * @category utils
68
+ * @since 0.1.0
69
+ */
70
+ export const apply = (
71
+ states: ReadonlyMap<string, State>,
72
+ event: { readonly _tag: string }
73
+ ): ReadonlyMap<string, State> => {
74
+ if (!AssistantEvent.isLibraryEvent(event)) return states
75
+
76
+ const close = (ids: ReadonlyArray<string>) => {
77
+ const open = ids.filter((id) => states.get(id)?.open === true)
78
+
79
+ if (open.length === 0) return states
80
+
81
+ const next = new Map(states)
82
+
83
+ for (const id of open) next.set(id, { open: false, answer: undefined })
84
+
85
+ return next
86
+ }
87
+
88
+ if (Predicate.isTagged(event, "BlocksClosed")) return close(event.messageIds)
89
+
90
+ if (Predicate.isTagged(event, "AssistantMessage")) {
91
+ const closed = close(event.closes ?? [])
92
+
93
+ return event.block === undefined ? closed : new Map(closed).set(event.id, { open: true, answer: undefined })
94
+ }
95
+
96
+ if (!Predicate.isTagged(event, "BlockAnswer") || states.get(event.messageId)?.open !== true) return states
97
+
98
+ return new Map(states).set(event.messageId, { open: false, answer: event })
99
+ }
100
+
101
+ /**
102
+ * Returns the state of every message's block, by message id.
103
+ *
104
+ * @category utils
105
+ * @since 0.1.0
106
+ */
107
+ export const fromEntries = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): ReadonlyMap<string, State> => {
108
+ let states: ReadonlyMap<string, State> = new Map()
109
+
110
+ for (const entry of entries) states = apply(states, entry.event)
111
+
112
+ return states
113
+ }
114
+
115
+ /**
116
+ * Returns the ids of the messages whose blocks are still open after the user typed the given
117
+ * number of messages without using them.
118
+ *
119
+ * **Details**
120
+ *
121
+ * These are the blocks {@link closeStale} closes. Decision blocks never go stale, since work waits
122
+ * on them and closing one would leave it waiting.
123
+ *
124
+ * @category utils
125
+ * @since 0.1.0
126
+ */
127
+ export const staleBlocks = (
128
+ entries: ReadonlyArray<AssistantEvent.AnyEntry>,
129
+ options: { readonly messages: number }
130
+ ): ReadonlyArray<string> => {
131
+ const open = [...fromEntries(entries)].flatMap(([id, state]) => (state.open ? [id] : []))
132
+
133
+ return open.filter((id) => {
134
+ const shown = entries.findIndex(
135
+ ({ event }) =>
136
+ AssistantEvent.isLibraryEvent(event) && Predicate.isTagged(event, "AssistantMessage") && event.id === id
137
+ )
138
+
139
+ const message = entries[shown]?.event
140
+
141
+ if (
142
+ message === undefined ||
143
+ (AssistantEvent.isLibraryEvent(message) &&
144
+ Predicate.isTagged(message, "AssistantMessage") &&
145
+ message.block?.kind === Decision.kind)
146
+ )
147
+ return false
148
+
149
+ const typed = entries.slice(shown + 1).filter(({ event }) => Predicate.isTagged(event, "UserMessage")).length
150
+
151
+ return typed >= options.messages
152
+ })
153
+ }
154
+
155
+ /**
156
+ * Closes the blocks the user moved on from, which are still open after the given number of typed
157
+ * messages.
158
+ *
159
+ * **Details**
160
+ *
161
+ * Run it from a turn, before or after its loop. It records a `BlocksClosed` event, so every
162
+ * client folds the blocks closed.
163
+ *
164
+ * @see {@link staleBlocks} for the blocks it closes
165
+ *
166
+ * @category utils
167
+ * @since 0.1.0
168
+ */
169
+ export const closeStale = Effect.fn(function* (options: { readonly messages: number }) {
170
+ const chat = yield* CurrentChat
171
+ const stale = staleBlocks(yield* chat.entries, options)
172
+
173
+ if (stale.length > 0) yield* chat.append(AssistantEvent.BlocksClosed.make({ messageIds: stale }))
174
+ })
175
+
176
+ /**
177
+ * Represents what a renderer receives for one block.
178
+ *
179
+ * @category models
180
+ * @since 0.1.0
181
+ */
182
+ export interface RenderArgs<B> {
183
+ readonly text: string
184
+ readonly props: PropsOf<B>
185
+ /**
186
+ * Whether the block is open. It is `false` once the block is answered, with the block or in
187
+ * words; show it read-only then.
188
+ */
189
+ readonly open: boolean
190
+ /** The user's answer, if they used the block. */
191
+ readonly answer: AnswerOf<B> | undefined
192
+ /**
193
+ * For the decision block, the call each decision is for (its tool and params) and how it was
194
+ * decided. Empty for other blocks.
195
+ */
196
+ readonly decisions: ReadonlyArray<ChatView.PendingDecision>
197
+ /** Sends an answer. Only call it while `open`. */
198
+ readonly respond: (answer: AnswerOf<B>) => void
199
+ }
200
+
201
+ /**
202
+ * Represents a renderer for every kind in a kit.
203
+ *
204
+ * @category models
205
+ * @since 0.1.0
206
+ */
207
+ export type Renderers<Kit extends BlockKit, Out> = {
208
+ readonly [K in KindOf<Kit["blocks"][number]>]: (args: RenderArgs<BlockOf<Kit, K>>) => Out
209
+ }
210
+
211
+ /**
212
+ * Represents the options for rendering one message's block.
213
+ *
214
+ * @category models
215
+ * @since 0.1.0
216
+ */
217
+ export interface RenderOptions {
218
+ /** Submits an answer, for example over RPC. */
219
+ readonly respond: (answer: AssistantEvent.BlockAnswer) => void
220
+ }
221
+
222
+ /**
223
+ * Creates a client-side renderer for the blocks on a chat view's messages
224
+ * (`ChatView.AssistantMessage`).
225
+ *
226
+ * **Details**
227
+ *
228
+ * The renderers are type-checked, so every kind in the kit needs one, and each receives typed
229
+ * `props` and `answer`. A renderer's `respond` builds a `BlockAnswer` input, with the answer
230
+ * encoded by the block's answer schema, and passes it to the `respond` option to submit.
231
+ *
232
+ * The returned function returns `undefined` for a message without a block, or with a block it
233
+ * can't decode or has no renderer for.
234
+ *
235
+ * **Example** (Rendering blocks with React)
236
+ *
237
+ * ```tsx
238
+ * import { BlockKit, Decision } from "effect-assistant"
239
+ * import type { ReactNode } from "react"
240
+ *
241
+ * const Blocks = BlockKit.make([PickOne, Decision.block])
242
+ *
243
+ * const renderBlock = BlockKit.toClient<typeof Blocks, ReactNode>(Blocks, {
244
+ * pick_one: ({ props, open, respond }) =>
245
+ * props.options.map((option) => (
246
+ * <button key={option} disabled={!open} onClick={() => respond({ picked: option })}>
247
+ * {option}
248
+ * </button>
249
+ * )),
250
+ * decision: ({ props, open, respond }) => (
251
+ * <button disabled={!open} onClick={() => respond(Decision.answerAll(props, "approved"))}>
252
+ * {props.approveLabel ?? "Approve"}
253
+ * </button>
254
+ * )
255
+ * })
256
+ *
257
+ * // For each AssistantMessage in the chat's view:
258
+ * renderBlock(message, { respond: (answer) => submit(answer) })
259
+ * ```
260
+ *
261
+ * @category constructors
262
+ * @since 0.1.0
263
+ */
264
+ export function toClient<Kit extends BlockKit, Out>(
265
+ kit: Kit,
266
+ renderers: Renderers<Kit, Out>
267
+ ): (message: ChatView.AssistantMessage, options: RenderOptions) => Out | undefined
268
+ // The overload above checks that every kind has a renderer; here each one takes any block's
269
+ // arguments.
270
+ export function toClient<Out>(
271
+ kit: BlockKit,
272
+ renderers: Readonly<Record<string, (args: RenderArgs<Block.Any>) => Out>>
273
+ ): (message: ChatView.AssistantMessage, options: RenderOptions) => Out | undefined {
274
+ return (message, options) => {
275
+ if (message.block === undefined) return undefined
276
+
277
+ const { kind } = message.block
278
+ const block = kit.byKind.get(kind)
279
+ const render = renderers[kind]
280
+
281
+ if (block === undefined || render === undefined) return undefined
282
+
283
+ const props = Schema.decodeUnknownOption(block.props)(message.block.props)
284
+
285
+ if (Option.isNone(props)) return undefined
286
+
287
+ const given = message.block.answer
288
+ const answerCodec = block.answer === undefined ? undefined : Schema.toCodecJson(block.answer)
289
+
290
+ const answer =
291
+ given === undefined || answerCodec === undefined ? Option.none() : Schema.decodeUnknownOption(answerCodec)(given)
292
+
293
+ return render({
294
+ text: message.text,
295
+ props: props.value,
296
+ open: message.block.status === "open",
297
+ answer: Option.getOrUndefined(answer),
298
+ decisions: message.block.decisions ?? [],
299
+ respond: (value) =>
300
+ options.respond(
301
+ AssistantEvent.BlockAnswer.make({
302
+ // A block takes one answer: the same id every time, so a second click is stored once.
303
+ id: InputId.fromKey(`${message.id}:answer`),
304
+ messageId: message.id,
305
+ kind,
306
+ value: answerCodec === undefined ? value : Schema.encodeUnknownSync(answerCodec)(value)
307
+ })
308
+ )
309
+ })
310
+ }
311
+ }
package/src/ChatId.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * This module provides the id of a chat, which is `chat:` followed by a UUID.
3
+ *
4
+ * A chat id keys everything about a chat: its log, its cluster entity, and its trace session.
5
+ *
6
+ * @since 0.1.0
7
+ */
8
+ import * as Effect from "effect/Effect"
9
+ import * as Schema from "effect/Schema"
10
+ import * as Uuid from "./internal/uuid.ts"
11
+
12
+ /**
13
+ * Schema for chat ids of the form `` `chat:${uuid}` ``. The schema is branded, so a run id or a
14
+ * plain string can't be passed where a chat id is required.
15
+ *
16
+ * @category schemas
17
+ * @since 0.1.0
18
+ */
19
+ export const ChatId = Schema.TemplateLiteral(["chat:", Uuid.Uuid]).pipe(Schema.brand("~effect-assistant/ChatId"))
20
+
21
+ /**
22
+ * Represents a chat id.
23
+ *
24
+ * @category models
25
+ * @since 0.1.0
26
+ */
27
+ export type ChatId = typeof ChatId.Type
28
+
29
+ /**
30
+ * Creates a new random chat id using Effect's `Random`. Seed `Random` in tests to get the same ids
31
+ * on every run.
32
+ *
33
+ * @category constructors
34
+ * @since 0.1.0
35
+ */
36
+ export const make = (): Effect.Effect<ChatId> => Effect.map(Uuid.make, (uuid) => ChatId.make(`chat:${uuid}`))