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
@@ -2,7 +2,7 @@
2
2
  * Defines the events that can happen in a chat.
3
3
  *
4
4
  * A chat is an append-only log of these events, and the log is the source of truth. A chat client
5
- * receives the part of the log its user sees (`ChatView`), and the model's history is rebuilt
5
+ * receives the part of the log its user sees (its `Timeline`), and the model's history is rebuilt
6
6
  * from the log before every model call. Besides the library's events ({@link AssistantEvent}), a
7
7
  * log holds the application's own events ({@link StoredAppEvent}).
8
8
  *
@@ -11,8 +11,10 @@
11
11
  import * as Response from "effect/ai/Response"
12
12
  import * as Predicate from "effect/Predicate"
13
13
  import * as Schema from "effect/Schema"
14
+ import type * as SchemaAST from "effect/SchemaAST"
14
15
  import { ChatId } from "./ChatId.ts"
15
16
  import { InputId, make as makeInputId } from "./InputId.ts"
17
+ import { inputKey } from "./internal/input.ts"
16
18
  import { RunId } from "./RunId.ts"
17
19
 
18
20
  /**
@@ -138,13 +140,30 @@ export class SpecialistFinished extends Schema.TaggedClass<SpecialistFinished>()
138
140
  error: Schema.optional(Schema.String)
139
141
  }) {}
140
142
 
143
+ /**
144
+ * Represents the user's decision on a tool call that waits for their approval: `"approved"`, so
145
+ * the call runs, or `"rejected"`, with an optional short reason the model reads.
146
+ *
147
+ * Its id is fixed per call (see `ToolApproval.approve`), so a decision is stored once. A decision on
148
+ * a call that isn't waiting any more (decided, or expired) isn't stored.
149
+ *
150
+ * @category schemas
151
+ * @since 0.4.0
152
+ */
153
+ export class ApprovalDecision extends Schema.TaggedClass<ApprovalDecision>()("ApprovalDecision", {
154
+ id: InputId,
155
+ callId: Schema.String,
156
+ decision: Schema.Literals(["approved", "rejected"]),
157
+ reason: Schema.optional(Schema.String.check(Schema.isMaxLength(500)))
158
+ }) {}
159
+
141
160
  /**
142
161
  * Schema for every input `Assistant.submit` accepts.
143
162
  *
144
163
  * @category schemas
145
164
  * @since 0.1.0
146
165
  */
147
- export const Input = Schema.Union([UserMessage, BlockAnswer, SpecialistFinished])
166
+ export const Input = Schema.Union([UserMessage, BlockAnswer, SpecialistFinished, ApprovalDecision])
148
167
 
149
168
  /**
150
169
  * Represents an input to an assistant.
@@ -155,7 +174,8 @@ export const Input = Schema.Union([UserMessage, BlockAnswer, SpecialistFinished]
155
174
  export type Input = typeof Input.Type
156
175
 
157
176
  /**
158
- * Schema for the inputs from the chat's user, which are messages and block answers.
177
+ * Schema for the inputs from the chat's user, which are messages, block answers, and decisions on
178
+ * tool calls that wait for their approval.
159
179
  *
160
180
  * A turn owes each one a reply. It is also what a chat client usually may submit, since the app's
161
181
  * events come from the server.
@@ -163,7 +183,7 @@ export type Input = typeof Input.Type
163
183
  * @category schemas
164
184
  * @since 0.1.0
165
185
  */
166
- export const UserInput = Schema.Union([UserMessage, BlockAnswer])
186
+ export const UserInput = Schema.Union([UserMessage, BlockAnswer, ApprovalDecision])
167
187
 
168
188
  /**
169
189
  * Represents an input from the chat's user.
@@ -398,6 +418,36 @@ export class BlocksClosed extends Schema.TaggedClass<BlocksClosed>()("BlocksClos
398
418
  messageIds: Schema.Array(Schema.String)
399
419
  }) {}
400
420
 
421
+ /**
422
+ * Represents a tool call that waits for the user's approval instead of running: its tool needs
423
+ * approval (Effect's `needsApproval`).
424
+ *
425
+ * **Details**
426
+ *
427
+ * It holds the call's id, the tool, and its params, in JSON, as the model wrote them. The model
428
+ * reads it as the call's result, saying it waits for the user; the user's
429
+ * {@link ApprovalDecision} decides it, and an approved call runs then, with the same id.
430
+ *
431
+ * @category schemas
432
+ * @since 0.4.0
433
+ */
434
+ export class ApprovalRequested extends Schema.TaggedClass<ApprovalRequested>()("ApprovalRequested", {
435
+ callId: Schema.String,
436
+ tool: Schema.String,
437
+ params: Schema.Json
438
+ }) {}
439
+
440
+ /**
441
+ * Represents tool calls that waited for the user's approval and won't run: the user moved on, like
442
+ * the ones `ToolApproval.closeStale` expires. The model reads that they weren't approved.
443
+ *
444
+ * @category schemas
445
+ * @since 0.4.0
446
+ */
447
+ export class ApprovalsExpired extends Schema.TaggedClass<ApprovalsExpired>()("ApprovalsExpired", {
448
+ callIds: Schema.Array(Schema.String)
449
+ }) {}
450
+
401
451
  /**
402
452
  * Schema for every event the library defines.
403
453
  *
@@ -417,6 +467,9 @@ export const AssistantEvent = Schema.Union([
417
467
  SpecialistStarted,
418
468
  SpecialistFinished,
419
469
  BlocksClosed,
470
+ ApprovalRequested,
471
+ ApprovalDecision,
472
+ ApprovalsExpired,
420
473
  ChatDeleted
421
474
  ])
422
475
 
@@ -444,19 +497,43 @@ const libraryTags: ReadonlySet<string> = new Set(
444
497
  */
445
498
  export const AppTag = Schema.String.pipe(Schema.brand("~effect-assistant/AppTag"))
446
499
 
500
+ /**
501
+ * Represents the schema of one of the application's own events, as `Assistant.make`'s `events`
502
+ * takes it.
503
+ *
504
+ * **Details**
505
+ *
506
+ * It is a tagged struct or class (`Schema.TaggedStruct`, `Schema.TaggedClass`) whose `_tag` is a
507
+ * string literal other than the library's, and that needs no services. The log stores the event in
508
+ * the schema's JSON form.
509
+ *
510
+ * @category models
511
+ * @since 0.3.0
512
+ */
513
+ export type AppEventSchema = Schema.Codec<{ readonly _tag: string }, { readonly _tag: string }> & {
514
+ readonly fields: { readonly _tag: { readonly ast: SchemaAST.AST } }
515
+ }
516
+
447
517
  /**
448
518
  * Schema for one of the application's own events as it is stored, with its `_tag` and its fields
449
519
  * in JSON.
450
520
  *
451
- * An assistant made with `events: [AppEvent.make(Summary)]` decodes it to a `Summary` in
452
- * `CurrentChat`'s `entries` and in its `subscribe`.
521
+ * **Details**
522
+ *
523
+ * An assistant made with `events: [Summary]` decodes it to a `Summary` in `CurrentChat`'s
524
+ * `entries` and in its `subscribe`.
525
+ *
526
+ * An event that came in through the assistant's `submit`, from outside a turn, is an input, and the
527
+ * log marks it with a `"~effect-assistant/input": true` key (see {@link isAppInput}); one a turn
528
+ * appended has none. Decoding drops the key, so the app's schemas never see it.
453
529
  *
454
530
  * @category schemas
455
531
  * @since 0.1.0
456
532
  */
457
- export const StoredAppEvent = Schema.StructWithRest(Schema.Struct({ _tag: AppTag }), [
458
- Schema.Record(Schema.String, Schema.Json)
459
- ]).check(Schema.makeFilter((event) => !libraryTags.has(event._tag) || `${event._tag} is one of the library's events`))
533
+ export const StoredAppEvent = Schema.StructWithRest(
534
+ Schema.Struct({ _tag: AppTag, [inputKey]: Schema.optionalKey(Schema.Literal(true)) }),
535
+ [Schema.Record(Schema.String, Schema.Json)]
536
+ ).check(Schema.makeFilter((event) => !libraryTags.has(event._tag) || `${event._tag} is one of the library's events`))
460
537
 
461
538
  /**
462
539
  * Represents one of the application's own events, as it is stored.
@@ -474,6 +551,18 @@ export type StoredAppEvent = typeof StoredAppEvent.Type
474
551
  */
475
552
  export const isLibraryEvent = (event: { readonly _tag: string }): event is AssistantEvent => libraryTags.has(event._tag)
476
553
 
554
+ /**
555
+ * Returns whether one of the application's events, as the log stores it, is an input: it came in
556
+ * through the assistant's `submit`, from outside a turn, so storing it woke a turn. An event a turn
557
+ * appended (`CurrentChat`'s `append`) isn't, and neither is a decoded one, which no longer has the
558
+ * mark.
559
+ *
560
+ * @category guards
561
+ * @since 0.3.0
562
+ */
563
+ export const isAppInput = (event: { readonly _tag: string }): boolean =>
564
+ !isLibraryEvent(event) && Predicate.hasProperty(event, inputKey) && event[inputKey] === true
565
+
477
566
  /**
478
567
  * Represents a chat's entry with any event in it, either the library's or the application's own
479
568
  * (decoded or not).
@@ -516,10 +605,10 @@ export type Entry = typeof Entry.Type
516
605
 
517
606
  /**
518
607
  * Returns whether an event is one of the library's inputs, which are a user message, a block
519
- * answer, or a finished specialist run.
608
+ * answer, a finished specialist run, or a decision on a tool call waiting for approval.
520
609
  *
521
- * The application's events that wake a turn are inputs too, but only their assistant knows which
522
- * (see `AppEvent`).
610
+ * The application's events that came in through the assistant's `submit` are inputs too (see
611
+ * {@link isAppInput}).
523
612
  *
524
613
  * @category guards
525
614
  * @since 0.1.0
@@ -527,7 +616,8 @@ export type Entry = typeof Entry.Type
527
616
  export const isInput = (event: { readonly _tag: string }): event is Input =>
528
617
  Predicate.isTagged(event, "UserMessage") ||
529
618
  Predicate.isTagged(event, "BlockAnswer") ||
530
- Predicate.isTagged(event, "SpecialistFinished")
619
+ Predicate.isTagged(event, "SpecialistFinished") ||
620
+ Predicate.isTagged(event, "ApprovalDecision")
531
621
 
532
622
  /**
533
623
  * Returns whether an input starts a turn, which every input does except a run the assistant
@@ -546,7 +636,9 @@ export const isWaking = (input: Input): boolean =>
546
636
  * @since 0.1.0
547
637
  */
548
638
  export const isUserInput = (input: Input): input is UserInput =>
549
- Predicate.isTagged(input, "UserMessage") || Predicate.isTagged(input, "BlockAnswer")
639
+ Predicate.isTagged(input, "UserMessage") ||
640
+ Predicate.isTagged(input, "BlockAnswer") ||
641
+ Predicate.isTagged(input, "ApprovalDecision")
550
642
 
551
643
  /**
552
644
  * Returns whether an event ends a turn, and so says how far the assistant has read.
@@ -161,8 +161,10 @@ const toolsOf = <T extends Record<string, Tool.Any>>(toolkit: Toolkit.Toolkit<T>
161
161
  * the step's result and decides what comes next, with `AssistantLoop.next(state)` or
162
162
  * `AssistantLoop.stop()`.
163
163
  *
164
- * A tool with Effect's `needsApproval` set fails the loop before the model is called: the
165
- * library doesn't run calls that wait for approval (see the guide on involving the user).
164
+ * In a turn's loop, a call to a tool with Effect's `needsApproval` doesn't run: the step emits an
165
+ * `ApprovalRequested` for it, and ends the turn, which waits for the user (see `ToolApproval`). A
166
+ * call the user approved runs at the start of the next step, before the model is called. A
167
+ * specialist's run can't wait for the user: such a call fails, and the run reads why.
166
168
  *
167
169
  * @category constructors
168
170
  * @since 0.1.0
package/src/BlockKit.ts CHANGED
@@ -14,7 +14,7 @@ import * as Predicate from "effect/Predicate"
14
14
  import * as Schema from "effect/Schema"
15
15
  import * as AssistantEvent from "./AssistantEvent.ts"
16
16
  import type * as Block from "./Block.ts"
17
- import type * as ChatView from "./ChatView.ts"
17
+ import type * as Timeline from "./Timeline.ts"
18
18
  import { CurrentChat } from "./CurrentChat.ts"
19
19
  import * as InputId from "./InputId.ts"
20
20
 
@@ -210,8 +210,8 @@ export interface RenderOptions {
210
210
  }
211
211
 
212
212
  /**
213
- * Creates a client-side renderer for the blocks on a chat view's messages
214
- * (`ChatView.AssistantMessage`).
213
+ * Creates a client-side renderer for the blocks on the assistant's messages in a chat's timeline
214
+ * (`Timeline.AssistantMessage`).
215
215
  *
216
216
  * **Details**
217
217
  *
@@ -241,7 +241,7 @@ export interface RenderOptions {
241
241
  * order_draft: ({ props }) => <button onClick={() => placeOrder(props)}>Place order</button>
242
242
  * })
243
243
  *
244
- * // For each AssistantMessage in the chat's view:
244
+ * // For each AssistantMessage in the chat's timeline:
245
245
  * renderBlock(message, { respond: (answer) => submit(answer) })
246
246
  * ```
247
247
  *
@@ -251,13 +251,13 @@ export interface RenderOptions {
251
251
  export function toClient<Kit extends BlockKit, Out>(
252
252
  kit: Kit,
253
253
  renderers: Renderers<Kit, Out>
254
- ): (message: ChatView.AssistantMessage, options: RenderOptions) => Out | undefined
254
+ ): (message: Timeline.AssistantMessage, options: RenderOptions) => Out | undefined
255
255
  // The overload above checks that every kind has a renderer; here each one takes any block's
256
256
  // arguments.
257
257
  export function toClient<Out>(
258
258
  kit: BlockKit,
259
259
  renderers: Readonly<Record<string, (args: RenderArgs<Block.Any>) => Out>>
260
- ): (message: ChatView.AssistantMessage, options: RenderOptions) => Out | undefined {
260
+ ): (message: Timeline.AssistantMessage, options: RenderOptions) => Out | undefined {
261
261
  return (message, options) => {
262
262
  if (message.block === undefined) return undefined
263
263
 
package/src/Compaction.ts CHANGED
@@ -18,6 +18,8 @@ import * as AssistantActivity from "./AssistantActivity.ts"
18
18
  import * as AssistantEvent from "./AssistantEvent.ts"
19
19
  import * as BlockKit from "./BlockKit.ts"
20
20
  import { userText } from "./internal/history.ts"
21
+ import { toJsonText } from "./internal/schema.ts"
22
+ import * as ToolApproval from "./ToolApproval.ts"
21
23
 
22
24
  /**
23
25
  * Represents the fields a summary event must have: its text and the last `seq` it covers.
@@ -54,7 +56,7 @@ export const inputTokens = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): nu
54
56
 
55
57
  /**
56
58
  * Returns the items that were open at `throughSeq` and are still open: blocks the user hasn't
57
- * answered, and specialists that are working. The result is text for
59
+ * answered, specialists that are working, and tool calls waiting for the user's approval. The result is text for
58
60
  * the model, or `None` when nothing is open.
59
61
  *
60
62
  * @category utils
@@ -100,9 +102,24 @@ export const openItems = (
100
102
  : []
101
103
  )
102
104
 
105
+ const approvals = [...ToolApproval.fromEntries(entries).values()].flatMap((approval) =>
106
+ approval.status === "pending" &&
107
+ opened(
108
+ seqOf(
109
+ (event) =>
110
+ AssistantEvent.isLibraryEvent(event) &&
111
+ Predicate.isTagged(event, "ApprovalRequested") &&
112
+ event.callId === approval.request.callId
113
+ )
114
+ )
115
+ ? [`- your call to ${approval.request.tool} (${approval.request.callId}): ${toJsonText(approval.request.params)}`]
116
+ : []
117
+ )
118
+
103
119
  const sections = [
104
120
  blocks.length > 0 ? `Blocks the user hasn't answered:\n${blocks.join("\n")}` : "",
105
- specialists.length > 0 ? `Specialists still working:\n${specialists.join("\n")}` : ""
121
+ specialists.length > 0 ? `Specialists still working:\n${specialists.join("\n")}` : "",
122
+ approvals.length > 0 ? `Calls waiting for the user's approval:\n${approvals.join("\n")}` : ""
106
123
  ].filter((section) => section.length > 0)
107
124
 
108
125
  return sections.length === 0 ? Option.none() : Option.some(sections.join("\n\n"))
@@ -138,10 +155,14 @@ export const attachedFiles = (
138
155
  * ({@link openItems}), followed by the entries after the summary. When the chat has no summary,
139
156
  * the view returns every entry.
140
157
  *
158
+ * The app's other events after the summary stay entries, which the model doesn't read: map those
159
+ * it should read to messages after this view, as with any history view (see
160
+ * `Assistant.HistoryView`). A summary the handler appends wakes no turn.
161
+ *
141
162
  * **Example** (Compacting a long chat after each turn)
142
163
  *
143
164
  * ```ts
144
- * import { AppEvent, Assistant, type AssistantEvent, Compaction } from "effect-assistant"
165
+ * import { Assistant, type AssistantEvent, Compaction } from "effect-assistant"
145
166
  * import * as LanguageModel from "effect/ai/LanguageModel"
146
167
  * import * as Effect from "effect/Effect"
147
168
  * import * as Schema from "effect/Schema"
@@ -154,7 +175,7 @@ export const attachedFiles = (
154
175
  *
155
176
  * declare const transcript: (entries: ReadonlyArray<AssistantEvent.AnyEntry>) => string
156
177
  *
157
- * export const MyAssistant = Assistant.make("my-assistant", { events: [AppEvent.make(Summary)] })
178
+ * export const MyAssistant = Assistant.make("my-assistant", { events: [Summary] })
158
179
  *
159
180
  * const MyAssistantLive = MyAssistant.toLayer((turn) => {
160
181
  * const compact = Effect.gen(function* () {
@@ -40,7 +40,8 @@ export class CurrentChat extends Context.Service<
40
40
  /**
41
41
  * Appends events after the chat's last entry and sends them to the chat's subscribers. The
42
42
  * events can be the library's or the assistant's own (registered with its `events` option),
43
- * such as a summary. An event that wakes a turn is handled by the next turn.
43
+ * such as a summary. An app event appended here wakes no turn: only one that comes in through
44
+ * the assistant's `submit` does.
44
45
  */
45
46
  readonly append: (...events: ReadonlyArray<{ readonly _tag: string }>) => Effect.Effect<void>
46
47
  }
package/src/Skill.ts CHANGED
@@ -1,13 +1,19 @@
1
1
  /**
2
2
  * Defines skills, which are playbooks with step-by-step instructions for a kind of task.
3
3
  *
4
- * A loop lists its skills as a catalog in the prompt, with each skill's name and description
5
- * ({@link withCatalog}). The model loads a skill's instructions with the `load_skill` tool when a
6
- * task matches.
4
+ * A list of skills becomes a toolkit with one tool, `load_skill` ({@link toolkit}). Its
5
+ * description is the catalog, each skill's name and when to use it, and the model calls it to read
6
+ * a skill's instructions when a task matches. Merge it into a loop's toolkit like any other.
7
7
  *
8
8
  * @since 0.1.0
9
9
  */
10
- import type * as Instructions from "./Instructions.ts"
10
+ import * as Tool from "effect/ai/Tool"
11
+ import * as AiToolkit from "effect/ai/Toolkit"
12
+ import * as Effect from "effect/Effect"
13
+ import type * as Layer from "effect/Layer"
14
+ import * as Schema from "effect/Schema"
15
+ import * as Instructions from "./Instructions.ts"
16
+ import * as ToolFailure from "./ToolFailure.ts"
11
17
 
12
18
  /**
13
19
  * Represents a skill.
@@ -32,7 +38,7 @@ export interface Skill<R = never> {
32
38
  *
33
39
  * Write the description as a trigger ("Use when…"), since it's all the model sees until it loads
34
40
  * the skill. The instructions are text, or an Effect that is resolved each time the skill is
35
- * loaded.
41
+ * loaded. Offer skills to a loop with {@link toolkit}.
36
42
  *
37
43
  * **Example** (Defining a playbook for ordering campaigns)
38
44
  *
@@ -45,10 +51,6 @@ export interface Skill<R = never> {
45
51
  * 2. You need who it should reach and a budget. Ask for what's missing, one question at a time.
46
52
  * 3. When you have both, call start_campaign.`
47
53
  * })
48
- *
49
- * const MyAssistantLive = MyAssistant.toLayer((turn) =>
50
- * turn.loop({ instructions, skills: [orderingCampaigns] })
51
- * )
52
54
  * ```
53
55
  *
54
56
  * @category constructors
@@ -72,20 +74,128 @@ export const make = <R = never>(
72
74
  */
73
75
  export type Services<S> = S extends Skill<infer R> ? R : never
74
76
 
77
+ const loadSkill = (description: string) =>
78
+ Tool.make("load_skill", {
79
+ description,
80
+ parameters: Schema.Struct({ name: Schema.String }),
81
+ success: Schema.String,
82
+ failure: ToolFailure.ToolFailure
83
+ })
84
+
75
85
  /**
76
- * Returns the prompt with the skills' catalog appended, or the prompt unchanged when there are no
77
- * skills.
86
+ * Represents the `load_skill` tool, which reads a skill's instructions by name.
78
87
  *
79
- * @category utils
80
- * @since 0.1.0
88
+ * @category models
89
+ * @since 0.4.0
90
+ */
91
+ export type LoadSkill = ReturnType<typeof loadSkill>
92
+
93
+ /**
94
+ * Represents a list of skills as a toolkit: the `load_skill` tool, and the layer with its handler.
95
+ *
96
+ * `R` is the services the skills' instructions need, which the layer requires.
97
+ *
98
+ * @category models
99
+ * @since 0.4.0
100
+ */
101
+ export interface Toolkit<R = never> extends AiToolkit.Toolkit<{ readonly load_skill: LoadSkill }> {
102
+ /** The handler of `load_skill`, which resolves the instructions of these skills. */
103
+ readonly layer: Layer.Layer<Tool.Handler<"load_skill">, never, R>
104
+ }
105
+
106
+ const catalog = (skills: ReadonlyArray<Skill<unknown>>): string =>
107
+ [
108
+ "Load a skill, a playbook for a kind of task, by name, and follow it for the rest of the task. When one matches what you're doing, load it in the same step as your first lookups, not in a step of its own.",
109
+ "",
110
+ "Skills:",
111
+ ...skills.map((skill) => `- ${skill.name}: ${skill.description}`)
112
+ ].join("\n")
113
+
114
+ function toolkitOf<const Skills extends ReadonlyArray<Skill<unknown>>>(
115
+ skills: Skills
116
+ ): Toolkit<Services<Skills[number]>>
117
+ // The overload states what the layer requires: the services of these skills' instructions.
118
+ function toolkitOf(skills: ReadonlyArray<Skill<unknown>>): Toolkit<unknown> {
119
+ const byName = new Map(skills.map((skill) => [skill.name, skill]))
120
+ const tools = AiToolkit.make(loadSkill(catalog(skills)))
121
+
122
+ const layer = tools.toLayer(
123
+ Effect.gen(function* () {
124
+ const context = yield* Effect.context<unknown>()
125
+
126
+ return {
127
+ load_skill: Effect.fnUntraced(function* ({ name }) {
128
+ const skill = byName.get(name)
129
+
130
+ if (skill === undefined) {
131
+ return yield* ToolFailure.fail(
132
+ `No skill named ${name}. The skills are: ${skills.map((known) => known.name).join(", ")}.`
133
+ )
134
+ }
135
+
136
+ const resolved = yield* Instructions.resolve(skill.instructions).pipe(Effect.provideContext(context))
137
+
138
+ return resolved.text
139
+ })
140
+ }
141
+ })
142
+ )
143
+
144
+ return Object.assign(tools, { layer })
145
+ }
146
+
147
+ /**
148
+ * Creates a toolkit from a list of skills: the `load_skill` tool, whose description lists each
149
+ * skill's name and when to use it, and its handler's `layer`.
150
+ *
151
+ * **Details**
152
+ *
153
+ * Merge the toolkit into a loop's own with `Toolkit.merge`, and provide its `layer`, which requires
154
+ * the services the skills' instructions need. The model loads a skill by name; a name that isn't
155
+ * in the list is a failure the model reads, with the names there are.
156
+ *
157
+ * The handler resolves only the skills of its own list. To offer each user the skills they may
158
+ * use, build the toolkit in the turn from that user's list, and provide its layer to the loop's
159
+ * stream (`Stream.provide(skills.layer)`): building one does no work.
160
+ *
161
+ * **Example** (Giving an assistant its playbooks)
162
+ *
163
+ * ```ts
164
+ * import { Skill } from "effect-assistant"
165
+ * import * as Layer from "effect/Layer"
166
+ * import * as Toolkit from "effect/ai/Toolkit"
167
+ *
168
+ * const AssistantSkills = Skill.toolkit([orderingCampaigns, sharingAssets])
169
+ *
170
+ * const MyAssistantLive = MyAssistant.toLayer((turn) =>
171
+ * turn.loop({ instructions, toolkit: Toolkit.merge(MyTools, AssistantSkills) })
172
+ * ).pipe(Layer.provide([MyToolsLive, AssistantSkills.layer]))
173
+ * ```
174
+ *
175
+ * **Example** (Offering each user the skills their role allows)
176
+ *
177
+ * ```ts
178
+ * import { Skill } from "effect-assistant"
179
+ * import * as Effect from "effect/Effect"
180
+ * import * as Stream from "effect/Stream"
181
+ * import * as Toolkit from "effect/ai/Toolkit"
182
+ *
183
+ * const MyAssistantLive = MyAssistant.toLayer((turn) =>
184
+ * Stream.unwrap(
185
+ * Effect.map(roleOf(turn.chatId), (role) => {
186
+ * const skills = Skill.toolkit(allSkills.filter((skill) => allowed(role, skill)))
187
+ *
188
+ * return turn
189
+ * .loop({ instructions, toolkit: Toolkit.merge(MyTools, skills) })
190
+ * .pipe(Stream.provide(skills.layer))
191
+ * })
192
+ * )
193
+ * )
194
+ * ```
195
+ *
196
+ * @category constructors
197
+ * @since 0.4.0
81
198
  */
82
- export const withCatalog = <R>(prompt: string, skills: ReadonlyArray<Skill<R>>): string =>
83
- skills.length === 0
84
- ? prompt
85
- : [
86
- prompt,
87
- "",
88
- "## Skills",
89
- "Playbooks for common tasks. When one matches what you're doing, load it with load_skill in the same step as your first lookups, and follow it.",
90
- ...skills.map((skill) => `- ${skill.name}: ${skill.description}`)
91
- ].join("\n")
199
+ export const toolkit: <const Skills extends ReadonlyArray<Skill<unknown>>>(
200
+ skills: Skills
201
+ ) => Toolkit<Services<Skills[number]>> = toolkitOf
package/src/Specialist.ts CHANGED
@@ -23,7 +23,6 @@ import type { ChatId } from "./ChatId.ts"
23
23
  import type { CurrentChat } from "./CurrentChat.ts"
24
24
  import type * as Instructions from "./Instructions.ts"
25
25
  import type { RunId } from "./RunId.ts"
26
- import type { Skill } from "./Skill.ts"
27
26
 
28
27
  const TypeId = "~effect-assistant/Specialist"
29
28
 
@@ -123,12 +122,13 @@ export type RunServices = CurrentChat | Runner | LanguageModel.LanguageModel
123
122
  * @category models
124
123
  * @since 0.1.0
125
124
  */
126
- export interface LoopOptions<T extends Record<string, Tool.Any> = Record<never, never>, RS = never> {
125
+ export interface LoopOptions<T extends Record<string, Tool.Any> = Record<never, never>> {
127
126
  readonly instructions: string | Instructions.Resolved
128
- /** The loop's tools, as a native toolkit whose handlers come from the layer. */
127
+ /**
128
+ * The loop's tools, as a native toolkit whose handlers come from the layer. Merge in a
129
+ * `Skill.toolkit(...)` for playbooks.
130
+ */
129
131
  readonly toolkit?: Toolkit.Toolkit<T> | undefined
130
- /** Playbooks, listed as a catalog in the prompt and loaded with `load_skill` when one matches. */
131
- readonly skills?: ReadonlyArray<Skill<RS>> | undefined
132
132
  /** The model calls allowed per brief. Defaults to 8. */
133
133
  readonly maxSteps?: number | undefined
134
134
  }
@@ -149,13 +149,13 @@ export interface Run {
149
149
  * (follow-ups and steering). Read it before every model call, as the loop does.
150
150
  */
151
151
  readonly history: Effect.Effect<ReadonlyArray<Prompt.Message>>
152
- /** Runs the agent loop over the run's history, with the given instructions, tools, and skills. */
153
- readonly loop: <T extends Record<string, Tool.Any> = Record<never, never>, RS = never>(
154
- options: LoopOptions<T, RS>
152
+ /** Runs the agent loop over the run's history, with the given instructions and tools. */
153
+ readonly loop: <T extends Record<string, Tool.Any> = Record<never, never>>(
154
+ options: LoopOptions<T>
155
155
  ) => Stream.Stream<
156
156
  AssistantLoop.Event,
157
157
  AiError.AiError,
158
- LanguageModel.LanguageModel | AssistantLoop.ToolkitServices<T> | RS
158
+ LanguageModel.LanguageModel | AssistantLoop.ToolkitServices<T>
159
159
  >
160
160
  }
161
161