effect-assistant 0.2.0 → 0.4.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 (113) hide show
  1. package/dist/Assistant.d.ts +77 -37
  2. package/dist/Assistant.d.ts.map +1 -1
  3. package/dist/Assistant.js +11 -4
  4. package/dist/Assistant.js.map +1 -1
  5. package/dist/AssistantActivity.d.ts +10 -16
  6. package/dist/AssistantActivity.d.ts.map +1 -1
  7. package/dist/AssistantActivity.js +11 -4
  8. package/dist/AssistantActivity.js.map +1 -1
  9. package/dist/AssistantEngine.d.ts +39 -1
  10. package/dist/AssistantEngine.d.ts.map +1 -1
  11. package/dist/AssistantEngine.js +81 -28
  12. package/dist/AssistantEngine.js.map +1 -1
  13. package/dist/AssistantEvent.d.ts +104 -11
  14. package/dist/AssistantEvent.d.ts.map +1 -1
  15. package/dist/AssistantEvent.js +88 -13
  16. package/dist/AssistantEvent.js.map +1 -1
  17. package/dist/AssistantLoop.d.ts +4 -2
  18. package/dist/AssistantLoop.d.ts.map +1 -1
  19. package/dist/AssistantLoop.js +4 -2
  20. package/dist/AssistantLoop.js.map +1 -1
  21. package/dist/BlockKit.d.ts +4 -4
  22. package/dist/BlockKit.js +1 -1
  23. package/dist/BlockKit.js.map +1 -1
  24. package/dist/ChatLog.js +1 -1
  25. package/dist/Compaction.d.ts +7 -3
  26. package/dist/Compaction.d.ts.map +1 -1
  27. package/dist/Compaction.js +19 -8
  28. package/dist/Compaction.js.map +1 -1
  29. package/dist/CurrentChat.d.ts +2 -1
  30. package/dist/CurrentChat.d.ts.map +1 -1
  31. package/dist/CurrentChat.js.map +1 -1
  32. package/dist/Instructions.js +1 -1
  33. package/dist/Skill.d.ts +86 -11
  34. package/dist/Skill.d.ts.map +1 -1
  35. package/dist/Skill.js +93 -18
  36. package/dist/Skill.js.map +1 -1
  37. package/dist/Specialist.d.ts +7 -7
  38. package/dist/Specialist.d.ts.map +1 -1
  39. package/dist/Specialist.js.map +1 -1
  40. package/dist/Timeline.d.ts +516 -0
  41. package/dist/Timeline.d.ts.map +1 -0
  42. package/dist/Timeline.js +631 -0
  43. package/dist/Timeline.js.map +1 -0
  44. package/dist/ToolApproval.d.ts +146 -0
  45. package/dist/ToolApproval.d.ts.map +1 -0
  46. package/dist/ToolApproval.js +201 -0
  47. package/dist/ToolApproval.js.map +1 -0
  48. package/dist/ToolDiscovery.d.ts +6 -4
  49. package/dist/ToolDiscovery.d.ts.map +1 -1
  50. package/dist/ToolDiscovery.js +5 -4
  51. package/dist/ToolDiscovery.js.map +1 -1
  52. package/dist/index.d.ts +4 -4
  53. package/dist/index.js +4 -4
  54. package/dist/internal/chat.js +9 -7
  55. package/dist/internal/chat.js.map +1 -1
  56. package/dist/internal/events.d.ts +15 -16
  57. package/dist/internal/events.d.ts.map +1 -1
  58. package/dist/internal/events.js +29 -28
  59. package/dist/internal/events.js.map +1 -1
  60. package/dist/internal/history.js +53 -22
  61. package/dist/internal/history.js.map +1 -1
  62. package/dist/internal/input.js +7 -0
  63. package/dist/internal/input.js.map +1 -0
  64. package/dist/internal/loop.d.ts +10 -5
  65. package/dist/internal/loop.d.ts.map +1 -1
  66. package/dist/internal/loop.js +55 -22
  67. package/dist/internal/loop.js.map +1 -1
  68. package/dist/internal/specialists.js +10 -16
  69. package/dist/internal/specialists.js.map +1 -1
  70. package/dist/internal/toolkit.d.ts +5 -7
  71. package/dist/internal/toolkit.d.ts.map +1 -1
  72. package/dist/internal/toolkit.js +28 -18
  73. package/dist/internal/toolkit.js.map +1 -1
  74. package/dist/internal/tools.js +1 -1
  75. package/dist/internal/turns.d.ts.map +1 -1
  76. package/dist/internal/turns.js +35 -42
  77. package/dist/internal/turns.js.map +1 -1
  78. package/package.json +1 -1
  79. package/src/Assistant.ts +80 -40
  80. package/src/AssistantActivity.ts +11 -18
  81. package/src/AssistantEngine.ts +110 -43
  82. package/src/AssistantEvent.ts +106 -14
  83. package/src/AssistantLoop.ts +4 -2
  84. package/src/BlockKit.ts +6 -6
  85. package/src/Compaction.ts +25 -4
  86. package/src/CurrentChat.ts +2 -1
  87. package/src/Skill.ts +133 -23
  88. package/src/Specialist.ts +9 -9
  89. package/src/Timeline.ts +947 -0
  90. package/src/ToolApproval.ts +249 -0
  91. package/src/ToolDiscovery.ts +8 -3
  92. package/src/index.ts +10 -10
  93. package/src/internal/chat.ts +15 -5
  94. package/src/internal/events.ts +43 -43
  95. package/src/internal/history.ts +94 -32
  96. package/src/internal/input.ts +3 -0
  97. package/src/internal/loop.ts +120 -34
  98. package/src/internal/specialists.ts +7 -12
  99. package/src/internal/toolkit.ts +47 -30
  100. package/src/internal/turns.ts +52 -58
  101. package/dist/AppEvent.d.ts +0 -111
  102. package/dist/AppEvent.d.ts.map +0 -1
  103. package/dist/AppEvent.js +0 -71
  104. package/dist/AppEvent.js.map +0 -1
  105. package/dist/ChatView.d.ts +0 -408
  106. package/dist/ChatView.d.ts.map +0 -1
  107. package/dist/ChatView.js +0 -443
  108. package/dist/ChatView.js.map +0 -1
  109. package/dist/internal/skills.js +0 -28
  110. package/dist/internal/skills.js.map +0 -1
  111. package/src/AppEvent.ts +0 -134
  112. package/src/ChatView.ts +0 -587
  113. package/src/internal/skills.ts +0 -37
package/src/Assistant.ts CHANGED
@@ -24,7 +24,6 @@ import * as Predicate from "effect/Predicate"
24
24
  import * as Schema from "effect/Schema"
25
25
  import type * as Scope from "effect/Scope"
26
26
  import * as Stream from "effect/Stream"
27
- import type * as AppEvent from "./AppEvent.ts"
28
27
  import { AssistantEngine } from "./AssistantEngine.ts"
29
28
  import * as AssistantEvent from "./AssistantEvent.ts"
30
29
  import type * as AssistantLoop from "./AssistantLoop.ts"
@@ -35,7 +34,6 @@ import type { CurrentChat } from "./CurrentChat.ts"
35
34
  import type * as Instructions from "./Instructions.ts"
36
35
  import * as AppEvents from "./internal/events.ts"
37
36
  import * as Turns from "./internal/turns.ts"
38
- import type { Skill } from "./Skill.ts"
39
37
  import type * as Specialist from "./Specialist.ts"
40
38
 
41
39
  const TypeId = "~effect-assistant/Assistant" as const
@@ -55,27 +53,24 @@ export type TurnServices = CurrentChat | Specialist.Runner
55
53
  * @category models
56
54
  * @since 0.1.0
57
55
  */
58
- export interface LoopOptions<T extends Record<string, Tool.Any> = Record<never, never>, RS = never, Events = never> {
56
+ export interface LoopOptions<T extends Record<string, Tool.Any> = Record<never, never>, Events = never> {
59
57
  /** The assistant's role and rules. The loop places `Instructions.protocol` before them. */
60
58
  readonly instructions: string | Instructions.Resolved
61
59
  /**
62
60
  * Tools the model can use in addition to the assistant's own. This is an `effect/ai` toolkit,
63
61
  * and its handlers come from the layer. Combine several toolkits with `Toolkit.merge`, for
64
- * example `Memory.Tools` or a `ToolDiscovery.make(...)` toolkit together with your own.
62
+ * example `Memory.Tools`, a `Skill.toolkit(...)`, or a `ToolDiscovery.make(...)` toolkit together
63
+ * with your own.
65
64
  */
66
65
  readonly toolkit?: Toolkit.Toolkit<T> | undefined
67
- /**
68
- * Playbooks the model can follow. The prompt lists them as a catalog, and the model loads one
69
- * with `load_skill` when a task matches it.
70
- */
71
- readonly skills?: ReadonlyArray<Skill<RS>> | undefined
72
66
  /** The maximum number of model calls per turn. Defaults to 16. */
73
67
  readonly maxSteps?: number | undefined
74
68
  /** The message the reply pass gives the model when the turn would end without a reply. */
75
69
  readonly replyNudge?: string | undefined
76
70
  /**
77
71
  * Selects which of the chat's entries the model reads, for example only those since the last
78
- * summary. Defaults to all entries.
72
+ * summary, and maps the app's own events it should read into prompt messages. Defaults to all
73
+ * entries, without the app's events.
79
74
  */
80
75
  readonly history?: HistoryView<Events> | undefined
81
76
  }
@@ -86,9 +81,43 @@ export interface LoopOptions<T extends Record<string, Tool.Any> = Record<never,
86
81
  *
87
82
  * **Details**
88
83
  *
89
- * The function receives the chat's entries and returns, in order, a mix of entries and
90
- * `Prompt.Message`s. The model reads the returned entries the same way it reads the full history,
91
- * and reads each prompt message unchanged.
84
+ * The function receives the chat's entries, the app's own events among them, and returns, in
85
+ * order, a mix of entries and `Prompt.Message`s. The model reads the returned entries the same way
86
+ * it reads the full history, and reads each prompt message unchanged, where the view put it. The
87
+ * model reads none of the app's events by itself: map those it should read to a message, and an
88
+ * app event's entry left in the result is dropped.
89
+ *
90
+ * A message stands right after the entry before it: one that came after the model's last read, or
91
+ * between a tool call and its result, moves after them, as an input does.
92
+ *
93
+ * **Example** (Telling the model about an order the user placed)
94
+ *
95
+ * ```ts
96
+ * import { Assistant, InputId } from "effect-assistant"
97
+ * import * as Prompt from "effect/ai/Prompt"
98
+ * import * as Schema from "effect/Schema"
99
+ *
100
+ * class OrderPlaced extends Schema.TaggedClass<OrderPlaced>()("OrderPlaced", {
101
+ * id: InputId.InputId,
102
+ * reference: Schema.String
103
+ * }) {}
104
+ *
105
+ * const MyAssistant = Assistant.make("my-assistant", { events: [OrderPlaced] })
106
+ *
107
+ * const withOrders: Assistant.HistoryView<OrderPlaced> = (entries) =>
108
+ * entries.map((entry) =>
109
+ * entry.event instanceof OrderPlaced
110
+ * ? Prompt.userMessage({
111
+ * content: [Prompt.textPart({ text: `The user placed order ${entry.event.reference}.` })]
112
+ * })
113
+ * : entry
114
+ * )
115
+ *
116
+ * // An `OrderPlaced` that the API submits wakes a turn, which reads it as this note.
117
+ * const MyAssistantLive = MyAssistant.toLayer((turn) =>
118
+ * turn.loop({ instructions: "Be brief.", history: withOrders })
119
+ * )
120
+ * ```
92
121
  *
93
122
  * @category models
94
123
  * @since 0.1.0
@@ -153,15 +182,15 @@ export interface Turn<Events = never> {
153
182
  readonly instructions: (instructions: string | Instructions.Resolved) => Instructions.Resolved
154
183
  /**
155
184
  * Runs the agent loop over the chat. The loop uses `Instructions.protocol`, the assistant's own
156
- * tools, and the instructions, tools, and skills in `options`. Every step is a tool call, and
185
+ * tools, and the instructions and tools in `options`. Every step is a tool call, and
157
186
  * the loop ends with `replyIfOwed`.
158
187
  */
159
- readonly loop: <T extends Record<string, Tool.Any> = Record<never, never>, RS = never>(
160
- options: LoopOptions<T, RS, Events>
188
+ readonly loop: <T extends Record<string, Tool.Any> = Record<never, never>>(
189
+ options: LoopOptions<T, Events>
161
190
  ) => Stream.Stream<
162
191
  AssistantLoop.Event,
163
192
  AiError.AiError,
164
- LanguageModel.LanguageModel | TurnServices | AssistantLoop.ToolkitServices<T> | RS
193
+ LanguageModel.LanguageModel | TurnServices | AssistantLoop.ToolkitServices<T>
165
194
  >
166
195
  /**
167
196
  * Runs one more pass when the turn would otherwise end without a reply to the user's latest
@@ -249,8 +278,8 @@ export interface Chat<Events = never> {
249
278
  readonly entries: Effect.Effect<ReadonlyArray<Entry<Events>>>
250
279
  /**
251
280
  * Appends events after the chat's last entry and sends them to the chat's subscribers. The
252
- * events can be the library's or the assistant's own, such as a summary. An event that wakes a
253
- * turn is handled by the next turn.
281
+ * events can be the library's or the assistant's own, such as a summary. None of them wakes a
282
+ * turn: only what comes in through the assistant's `submit` does.
254
283
  */
255
284
  append(...events: ReadonlyArray<AssistantEvent.AssistantEvent | Events>): Effect.Effect<void>
256
285
  }
@@ -266,8 +295,11 @@ export interface Chat<Events = never> {
266
295
  export interface Service<Events = never> {
267
296
  /**
268
297
  * Stores an input or one of the assistant's own events, then returns. Examples are a user's
269
- * message or block answer, a workflow's progress, or the draft the user opened. The assistant
270
- * works on events that wake a turn in the background.
298
+ * message or block answer, a workflow's progress, or the order the user placed. Each one is an
299
+ * input: storing it wakes a turn, which the assistant runs in the background. The handler decides
300
+ * what the turn does with it, and an app event the model should read needs a history view that
301
+ * maps it (see {@link HistoryView}). To store an app event without waking a turn, append it from
302
+ * within the chat instead (`yield* MyAssistant.Chat`).
271
303
  *
272
304
  * Fails with `GetChatError` when the app's `Chats` service reports that the chat can't be used,
273
305
  * because the app doesn't know the chat or has deleted it.
@@ -279,9 +311,10 @@ export interface Service<Events = never> {
279
311
  * A reader that already has the earlier entries passes `after` and receives only the entries
280
312
  * after that `seq`.
281
313
  *
282
- * For a chat client, pipe the whole log into `ChatView.updates(MyAssistant, { after })`, which
283
- * streams the chat as its user sees it. Its `after` goes there, not here: the view of a message
284
- * depends on earlier entries.
314
+ * For a chat client, pipe the whole log into `Timeline.changes(MyAssistant, { after })`, which
315
+ * streams the chat's timeline, as its user sees it. Its `after` goes there, not here: a message
316
+ * on the timeline depends on earlier entries. The timeline shows only the app events named in its
317
+ * `show`, none by default (see `Timeline.changes`).
285
318
  */
286
319
  readonly subscribe: (
287
320
  chatId: ChatId,
@@ -323,8 +356,8 @@ export interface Assistant<
323
356
  readonly blocks: BlockKit.BlockKit | undefined
324
357
  /** The specialists the assistant can hand work to. */
325
358
  readonly specialists: ReadonlyArray<Specs>
326
- /** The app's own events the assistant registered. */
327
- readonly events: ReadonlyArray<AppEvent.Any>
359
+ /** The schemas of the app's own events the assistant stores. */
360
+ readonly events: ReadonlyArray<AssistantEvent.AppEventSchema>
328
361
  /**
329
362
  * Creates a layer that registers the assistant's handler with the engine and provides the
330
363
  * assistant's service.
@@ -387,9 +420,9 @@ export interface Assistant<
387
420
  *
388
421
  * export const MyAssistant = Assistant.make("my-assistant")
389
422
  *
390
- * // Requires `ChatLog` and `Sharding`. The cluster's runners run the chats.
423
+ * // Requires `Sharding`. The cluster's runners run the chats, with `AssistantEngine.layerCluster`.
391
424
  * const MyAssistantClient = MyAssistant.layerClient.pipe(
392
- * Layer.provide(AssistantEngine.layerCluster)
425
+ * Layer.provide(AssistantEngine.layerClusterClient)
393
426
  * )
394
427
  * ```
395
428
  */
@@ -419,28 +452,35 @@ export type Any = Assistant<any, any, any>
419
452
  */
420
453
  export const isAssistant = (u: unknown): u is Any => Predicate.hasProperty(u, TypeId)
421
454
 
422
- function entrySchema<E>(events: ReadonlyArray<AppEvent.Any>): Schema.Codec<Entry<E>, Schema.Json>
455
+ function entrySchema<E>(events: ReadonlyArray<AssistantEvent.AppEventSchema>): Schema.Codec<Entry<E>, Schema.Json>
423
456
  // The overload states the decoded type: the library's events, or one of these schemas' types.
424
- function entrySchema(events: ReadonlyArray<AppEvent.Any>): Schema.Top {
457
+ function entrySchema(events: ReadonlyArray<AssistantEvent.AppEventSchema>): Schema.Top {
425
458
  return Schema.toCodecJson(
426
459
  Schema.Struct({
427
460
  chatId: ChatId,
428
461
  seq: Schema.Int,
429
462
  at: Schema.Finite,
430
- event: Schema.Union([AssistantEvent.AssistantEvent, ...events.map((event) => event.schema)])
463
+ event: Schema.Union([AssistantEvent.AssistantEvent, ...events])
431
464
  })
432
465
  )
433
466
  }
434
467
 
435
468
  /**
436
- * Creates an assistant definition from its name, its UI blocks, its specialists, and the app's own
437
- * events it stores (see `AppEvent`).
469
+ * Creates an assistant definition from its name, its UI blocks, its specialists, and the schemas of
470
+ * the app's own events it stores.
438
471
  *
439
472
  * **Details**
440
473
  *
441
474
  * The definition holds no code that runs turns. Provide a handler with its `toLayer`, then yield
442
475
  * the definition to get the assistant's {@link Service}.
443
476
  *
477
+ * The definition only says which of the app's events a chat stores. What happens with one is up
478
+ * to the places that use it. One that comes in through the service's `submit`, from outside a
479
+ * turn, is an input and wakes a turn, like a user's message; one a turn appends to its chat never
480
+ * does. The handler decides what a turn does, a history view says how the model reads an event
481
+ * ({@link HistoryView}), and the chat's timeline says which its user sees
482
+ * (`Timeline.changes`).
483
+ *
444
484
  * **Example** (Defining an assistant and submitting a message)
445
485
  *
446
486
  * ```ts
@@ -466,7 +506,7 @@ function entrySchema(events: ReadonlyArray<AppEvent.Any>): Schema.Top {
466
506
  export const make = <
467
507
  const Name extends string,
468
508
  const Specs extends ReadonlyArray<Specialist.Any> = readonly [],
469
- const Events extends ReadonlyArray<AppEvent.Any> = readonly []
509
+ const Events extends ReadonlyArray<AssistantEvent.AppEventSchema> = readonly []
470
510
  >(
471
511
  name: Name,
472
512
  options?: {
@@ -478,14 +518,14 @@ export const make = <
478
518
  */
479
519
  readonly specialists?: Specs | undefined
480
520
  /**
481
- * The app's own events, each created with `AppEvent.make(schema, { wakes, render })`.
482
- * `submit` and the chat's `append` store them, and the chat's `entries` read them back,
483
- * decoded. Events that wake a turn are inputs, and the model reads each event that has a
484
- * `render`.
521
+ * The schemas of the app's own events, each a tagged struct or class. `submit` and the chat's
522
+ * `append` store them, in their JSON form, and the chat's `entries` and `subscribe` read them
523
+ * back, decoded. Each needs a tag of its own that isn't one of the library's: `make` throws
524
+ * otherwise.
485
525
  */
486
526
  readonly events?: Events | undefined
487
527
  }
488
- ): Assistant<Name, Specs[number], AppEvent.Type<Events[number]>> => {
528
+ ): Assistant<Name, Specs[number], Events[number]["Type"]> => {
489
529
  const events = options?.events ?? []
490
530
 
491
531
  const definition: Turns.Definition = {
@@ -495,7 +535,7 @@ export const make = <
495
535
  app: AppEvents.make(events)
496
536
  }
497
537
 
498
- type OwnEvents = AppEvent.Type<Events[number]>
538
+ type OwnEvents = Events[number]["Type"]
499
539
 
500
540
  const Key = Context.Service<Identifier<Name>, Service<OwnEvents>>(`effect-assistant/Assistant/${name}`)
501
541
 
@@ -71,32 +71,25 @@ const withoutRun = (activity: State, runId: RunId): State => {
71
71
  return { ...activity, specialists }
72
72
  }
73
73
 
74
- /**
75
- * Represents the options for folding a chat's entries into its activity.
76
- *
77
- * @category models
78
- * @since 0.1.0
79
- */
80
- export interface Options {
81
- /**
82
- * The tags of the app's events that wake a turn (see `AppEvent`). These events count as inputs
83
- * too. Defaults to none.
84
- */
85
- readonly wakes?: ReadonlySet<string> | undefined
86
- }
87
-
88
74
  /**
89
75
  * Returns the activity after one more entry. Fold a chat's entries in order, or use
90
76
  * {@link fromEntries}.
91
77
  *
78
+ * **Details**
79
+ *
80
+ * One of the app's events counts as an input, and a turn as about to start, when it came in
81
+ * through the assistant's `submit`. The log marks it so (`AssistantEvent.isAppInput`), as
82
+ * `ChatLog.read` returns it; `subscribe` streams it decoded, without the mark, so there the turn
83
+ * shows from its `TurnStarted`.
84
+ *
92
85
  * @category combinators
93
86
  * @since 0.1.0
94
87
  */
95
- export const apply = (activity: State, entry: AssistantEvent.AnyEntry, options?: Options): State => {
88
+ export const apply = (activity: State, entry: AssistantEvent.AnyEntry): State => {
96
89
  const event = entry.event
97
90
 
98
91
  if (!AssistantEvent.isLibraryEvent(event)) {
99
- return options?.wakes?.has(event._tag) === true ? { ...activity, queued: true, lastInputSeq: entry.seq } : activity
92
+ return AssistantEvent.isAppInput(event) ? { ...activity, queued: true, lastInputSeq: entry.seq } : activity
100
93
  }
101
94
 
102
95
  if (AssistantEvent.isInput(event)) {
@@ -136,8 +129,8 @@ export const apply = (activity: State, entry: AssistantEvent.AnyEntry, options?:
136
129
  * @category constructors
137
130
  * @since 0.1.0
138
131
  */
139
- export const fromEntries = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, options?: Options): State =>
140
- entries.reduce((activity, entry) => apply(activity, entry, options), empty)
132
+ export const fromEntries = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): State =>
133
+ entries.reduce((activity, entry) => apply(activity, entry), empty)
141
134
 
142
135
  /**
143
136
  * Returns whether anything is working: a turn that is running or about to start, or a specialist
@@ -1,11 +1,12 @@
1
1
  /**
2
- * This module provides the `AssistantEngine` service, which runs assistants' chats, and its two
2
+ * This module provides the `AssistantEngine` service, which runs assistants' chats, and its
3
3
  * implementations.
4
4
  *
5
5
  * An assistant's `toLayer` registers the assistant with the engine, and the assistant's `submit`
6
6
  * and `subscribe` go through it. Pick one engine for the app, as with a workflow engine.
7
7
  * {@link layerMemory} runs chats in the process, for tests and single-process apps.
8
- * {@link layerCluster} runs each chat as a cluster entity, so a turn cut off by a crash resumes.
8
+ * {@link layerCluster} runs each chat as a cluster entity, so a turn cut off by a crash resumes, and
9
+ * {@link layerClusterClient} reaches those chats from a process that runs none.
9
10
  *
10
11
  * @since 0.1.0
11
12
  */
@@ -155,7 +156,7 @@ export const layerMemory: Layer.Layer<AssistantEngine, never, ChatLog> = Layer.e
155
156
 
156
157
  // A chat owes a turn when its last turn was cut off, or an input came after.
157
158
  const resumeAll = Effect.fnUntraced(function* (registration: Turns.Registration) {
158
- const { name, app } = registration.definition
159
+ const { name } = registration.definition
159
160
 
160
161
  for (const chatId of yield* log.unfinished) {
161
162
  const entries = yield* log.read(chatId)
@@ -163,7 +164,7 @@ export const layerMemory: Layer.Layer<AssistantEngine, never, ChatLog> = Layer.e
163
164
  const lastTurn = entries.findLast(({ event }) => Predicate.isTagged(event, "TurnStarted"))?.event
164
165
  const ours = Predicate.isTagged(lastTurn, "TurnStarted") && lastTurn.assistant === name
165
166
 
166
- if (ours && History.needsTurn(entries, app.wakes)) yield* withChat(name, chatId, (chat) => chat.resume)
167
+ if (ours && History.needsTurn(entries)) yield* withChat(name, chatId, (chat) => chat.resume)
167
168
  }
168
169
  })
169
170
 
@@ -212,6 +213,59 @@ const Delete = Rpc.make("Delete")
212
213
 
213
214
  const chatEntity = (assistant: string) => Entity.make(`Assistant/${assistant}`, [Submit, Subscribe, Delete])
214
215
 
216
+ type EngineClient = Omit<AssistantEngine["Service"], "register">
217
+
218
+ // Entity clients are made on first use, so a process that registers no assistant can still reach its chats.
219
+ const makeClusterClient = Effect.gen(function* () {
220
+ const context = yield* Effect.context<Sharding.Sharding>()
221
+ const clients = new Map<string, EntityClient>()
222
+
223
+ const clientOf = Effect.fnUntraced(function* (assistant: string) {
224
+ const known = clients.get(assistant)
225
+
226
+ if (known !== undefined) return known
227
+
228
+ const makeClient = yield* chatEntity(assistant).client.pipe(Effect.provide(context))
229
+
230
+ const client: EntityClient = {
231
+ submit: (chatId, input) => makeClient(chatId).Submit({ input }, { discard: true }).pipe(Effect.orDie),
232
+ subscribe: (chatId, after) => makeClient(chatId).Subscribe({ after }).pipe(Stream.orDie),
233
+ delete: (chatId) => makeClient(chatId).Delete().pipe(Effect.orDie)
234
+ }
235
+
236
+ clients.set(assistant, client)
237
+
238
+ return client
239
+ })
240
+
241
+ const engine: EngineClient = {
242
+ submit: Effect.fn("AssistantEngine.submit")(function* (
243
+ assistant: string,
244
+ chatId: ChatId,
245
+ input: AssistantEvent.Input | AssistantEvent.StoredAppEvent
246
+ ) {
247
+ const client = yield* clientOf(assistant)
248
+
249
+ yield* client.submit(chatId, input)
250
+ }),
251
+ subscribe: (assistant, chatId, after) =>
252
+ Stream.unwrap(
253
+ Effect.gen(function* () {
254
+ const client = yield* clientOf(assistant)
255
+
256
+ return client.subscribe(chatId, after)
257
+ })
258
+ ),
259
+ delete: Effect.fn("AssistantEngine.delete")(function* (assistant: string, chatId: ChatId) {
260
+ const client = yield* clientOf(assistant)
261
+
262
+ yield* client.delete(chatId)
263
+ })
264
+ }
265
+
266
+ return engine
267
+ })
268
+
215
269
  /**
216
270
  * Layer that runs chats as cluster entities, one entity per chat, keyed by chat id.
217
271
  *
@@ -249,6 +303,7 @@ const chatEntity = (assistant: string) => Entity.make(`Assistant/${assistant}`,
249
303
  * ```
250
304
  *
251
305
  * @see {@link layerMemory} for running chats in one process
306
+ * @see {@link layerClusterClient} for a process that only reaches chats the cluster runs
252
307
  *
253
308
  * @category layers
254
309
  * @since 0.1.0
@@ -258,7 +313,6 @@ export const layerCluster: Layer.Layer<AssistantEngine, never, ChatLog | Shardin
258
313
  Effect.gen(function* () {
259
314
  const context = yield* Effect.context<ChatLog | Sharding.Sharding>()
260
315
  const sharding = yield* Sharding.Sharding
261
- const clients = new Map<string, EntityClient>()
262
316
 
263
317
  const register = Effect.fnUntraced(function* (registration: Turns.Registration) {
264
318
  const ChatEntity = chatEntity(registration.definition.name)
@@ -295,49 +349,62 @@ export const layerCluster: Layer.Layer<AssistantEngine, never, ChatLog | Shardin
295
349
  })
296
350
  })
297
351
 
298
- // Made on first use, so a process that registers no assistant can still reach its chats.
299
- const clientOf = Effect.fnUntraced(function* (assistant: string) {
300
- const known = clients.get(assistant)
301
-
302
- if (known !== undefined) return known
303
-
304
- const makeClient = yield* chatEntity(assistant).client.pipe(Effect.provide(context))
305
-
306
- const client: EntityClient = {
307
- submit: (chatId, input) => makeClient(chatId).Submit({ input }, { discard: true }).pipe(Effect.orDie),
308
- subscribe: (chatId, after) => makeClient(chatId).Subscribe({ after }).pipe(Stream.orDie),
309
- delete: (chatId) => makeClient(chatId).Delete().pipe(Effect.orDie)
310
- }
311
-
312
- clients.set(assistant, client)
313
-
314
- return client
315
- })
352
+ const client = yield* makeClusterClient
316
353
 
317
354
  return AssistantEngine.of({
318
355
  register: (registration) => register(registration).pipe(Effect.provide(context)),
319
- submit: Effect.fn("AssistantEngine.submit")(function* (
320
- assistant: string,
321
- chatId: ChatId,
322
- input: AssistantEvent.Input | AssistantEvent.StoredAppEvent
323
- ) {
324
- const client = yield* clientOf(assistant)
325
-
326
- yield* client.submit(chatId, input)
327
- }),
328
- subscribe: (assistant, chatId, after) =>
329
- Stream.unwrap(
330
- Effect.gen(function* () {
331
- const client = yield* clientOf(assistant)
356
+ ...client
357
+ })
358
+ })
359
+ )
332
360
 
333
- return client.subscribe(chatId, after)
334
- })
335
- ),
336
- delete: Effect.fn("AssistantEngine.delete")(function* (assistant: string, chatId: ChatId) {
337
- const client = yield* clientOf(assistant)
361
+ /**
362
+ * Layer that reaches chats running as cluster entities elsewhere, and runs none itself.
363
+ *
364
+ * **When to use**
365
+ *
366
+ * Use it in a process that only submits to, subscribes to, and deletes chats that the cluster's
367
+ * runners run with {@link layerCluster}, such as a web server that provides an assistant's
368
+ * `layerClient`. It needs no `ChatLog`, since the runners store the chats.
369
+ *
370
+ * **Details**
371
+ *
372
+ * The layer requires `Sharding`. An assistant's `toLayer` can't register with it: it dies, since
373
+ * no chats run in this process.
374
+ *
375
+ * **Example** (A web server in front of the cluster)
376
+ *
377
+ * ```ts
378
+ * import { Assistant, AssistantEngine } from "effect-assistant"
379
+ * import type * as Sharding from "effect/cluster/Sharding"
380
+ * import * as Layer from "effect/Layer"
381
+ *
382
+ * declare const ShardingLive: Layer.Layer<Sharding.Sharding>
383
+ *
384
+ * export const MyAssistant = Assistant.make("my-assistant")
385
+ *
386
+ * const MyAssistantClient = MyAssistant.layerClient.pipe(
387
+ * Layer.provide(AssistantEngine.layerClusterClient),
388
+ * Layer.provide(ShardingLive)
389
+ * )
390
+ * ```
391
+ *
392
+ * @see {@link layerCluster} for the runners that run the chats
393
+ *
394
+ * @category layers
395
+ * @since 0.3.0
396
+ */
397
+ export const layerClusterClient: Layer.Layer<AssistantEngine, never, Sharding.Sharding> = Layer.effect(
398
+ AssistantEngine,
399
+ Effect.gen(function* () {
400
+ const client = yield* makeClusterClient
338
401
 
339
- yield* client.delete(chatId)
340
- })
402
+ return AssistantEngine.of({
403
+ register: (registration) =>
404
+ Effect.die(
405
+ `The ${registration.definition.name} assistant can't register with AssistantEngine.layerClusterClient, which runs no chats: provide AssistantEngine.layerCluster where its toLayer runs.`
406
+ ),
407
+ ...client
341
408
  })
342
409
  })
343
410
  )