effect-assistant 0.3.0 → 0.5.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 (91) hide show
  1. package/dist/Assistant.d.ts +5 -11
  2. package/dist/Assistant.d.ts.map +1 -1
  3. package/dist/Assistant.js.map +1 -1
  4. package/dist/AssistantEvent.d.ts +66 -7
  5. package/dist/AssistantEvent.d.ts.map +1 -1
  6. package/dist/AssistantEvent.js +74 -9
  7. package/dist/AssistantEvent.js.map +1 -1
  8. package/dist/AssistantLoop.d.ts +4 -2
  9. package/dist/AssistantLoop.d.ts.map +1 -1
  10. package/dist/AssistantLoop.js +4 -2
  11. package/dist/AssistantLoop.js.map +1 -1
  12. package/dist/Block.d.ts +63 -1
  13. package/dist/Block.d.ts.map +1 -1
  14. package/dist/Block.js +63 -2
  15. package/dist/Block.js.map +1 -1
  16. package/dist/BlockKit.d.ts +25 -3
  17. package/dist/BlockKit.d.ts.map +1 -1
  18. package/dist/BlockKit.js +14 -12
  19. package/dist/BlockKit.js.map +1 -1
  20. package/dist/Compaction.d.ts +1 -1
  21. package/dist/Compaction.d.ts.map +1 -1
  22. package/dist/Compaction.js +11 -4
  23. package/dist/Compaction.js.map +1 -1
  24. package/dist/Skill.d.ts +85 -43
  25. package/dist/Skill.d.ts.map +1 -1
  26. package/dist/Skill.js +93 -44
  27. package/dist/Skill.js.map +1 -1
  28. package/dist/Specialist.d.ts +6 -9
  29. package/dist/Specialist.d.ts.map +1 -1
  30. package/dist/Specialist.js.map +1 -1
  31. package/dist/Timeline.d.ts +44 -6
  32. package/dist/Timeline.d.ts.map +1 -1
  33. package/dist/Timeline.js +87 -16
  34. package/dist/Timeline.js.map +1 -1
  35. package/dist/ToolApproval.d.ts +146 -0
  36. package/dist/ToolApproval.d.ts.map +1 -0
  37. package/dist/ToolApproval.js +201 -0
  38. package/dist/ToolApproval.js.map +1 -0
  39. package/dist/ToolDiscovery.d.ts +6 -4
  40. package/dist/ToolDiscovery.d.ts.map +1 -1
  41. package/dist/ToolDiscovery.js +5 -4
  42. package/dist/ToolDiscovery.js.map +1 -1
  43. package/dist/ToolResult.d.ts +56 -0
  44. package/dist/ToolResult.d.ts.map +1 -0
  45. package/dist/ToolResult.js +49 -0
  46. package/dist/ToolResult.js.map +1 -0
  47. package/dist/index.d.ts +4 -2
  48. package/dist/index.js +6 -4
  49. package/dist/internal/chat.js +4 -1
  50. package/dist/internal/chat.js.map +1 -1
  51. package/dist/internal/history.js +28 -3
  52. package/dist/internal/history.js.map +1 -1
  53. package/dist/internal/loop.d.ts +10 -5
  54. package/dist/internal/loop.d.ts.map +1 -1
  55. package/dist/internal/loop.js +60 -21
  56. package/dist/internal/loop.js.map +1 -1
  57. package/dist/internal/specialists.js +9 -16
  58. package/dist/internal/specialists.js.map +1 -1
  59. package/dist/internal/toolkit.d.ts +11 -7
  60. package/dist/internal/toolkit.d.ts.map +1 -1
  61. package/dist/internal/toolkit.js +74 -21
  62. package/dist/internal/toolkit.js.map +1 -1
  63. package/dist/internal/tools.js +39 -6
  64. package/dist/internal/tools.js.map +1 -1
  65. package/dist/internal/turns.d.ts.map +1 -1
  66. package/dist/internal/turns.js +24 -16
  67. package/dist/internal/turns.js.map +1 -1
  68. package/package.json +1 -1
  69. package/src/Assistant.ts +7 -13
  70. package/src/AssistantEvent.ts +73 -8
  71. package/src/AssistantLoop.ts +4 -2
  72. package/src/Block.ts +82 -1
  73. package/src/BlockKit.ts +37 -4
  74. package/src/Compaction.ts +19 -2
  75. package/src/Skill.ts +134 -57
  76. package/src/Specialist.ts +8 -11
  77. package/src/Timeline.ts +120 -21
  78. package/src/ToolApproval.ts +249 -0
  79. package/src/ToolDiscovery.ts +8 -3
  80. package/src/ToolResult.ts +80 -0
  81. package/src/index.ts +10 -0
  82. package/src/internal/chat.ts +11 -1
  83. package/src/internal/history.ts +58 -3
  84. package/src/internal/loop.ts +138 -35
  85. package/src/internal/specialists.ts +7 -13
  86. package/src/internal/toolkit.ts +102 -35
  87. package/src/internal/tools.ts +81 -5
  88. package/src/internal/turns.ts +31 -10
  89. package/dist/internal/skills.js +0 -33
  90. package/dist/internal/skills.js.map +0 -1
  91. package/src/internal/skills.ts +0 -48
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"))
package/src/Skill.ts CHANGED
@@ -1,14 +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 Tool from "effect/ai/Tool"
11
- 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"
12
17
 
13
18
  /**
14
19
  * Represents a skill.
@@ -24,11 +29,6 @@ export interface Skill<R = never> {
24
29
  readonly description: string
25
30
  /** Text, or an Effect resolved each time the skill is loaded. */
26
31
  readonly instructions: Instructions.Instructions<R>
27
- /**
28
- * The tools its instructions use. A loop offers the skill only when its toolkit has every one of
29
- * them, matched by name. Unset, a loop always offers it.
30
- */
31
- readonly tools?: ReadonlyArray<Tool.Any> | undefined
32
32
  }
33
33
 
34
34
  /**
@@ -38,12 +38,7 @@ export interface Skill<R = never> {
38
38
  *
39
39
  * Write the description as a trigger ("Use when…"), since it's all the model sees until it loads
40
40
  * the skill. The instructions are text, or an Effect that is resolved each time the skill is
41
- * loaded.
42
- *
43
- * `tools` lists the tools the instructions use. A loop whose toolkit lacks any of them, matched by
44
- * name, leaves the skill out of its catalog and of `load_skill`, so a loop that gets its tools per
45
- * user (by role, say) never offers a playbook it can't follow. A tool that only `ToolDiscovery`
46
- * finds isn't in the toolkit, so it doesn't count. Without `tools`, every loop offers the skill.
41
+ * loaded. Offer skills to a loop with {@link toolkit}.
47
42
  *
48
43
  * **Example** (Defining a playbook for ordering campaigns)
49
44
  *
@@ -56,30 +51,6 @@ export interface Skill<R = never> {
56
51
  * 2. You need who it should reach and a budget. Ask for what's missing, one question at a time.
57
52
  * 3. When you have both, call start_campaign.`
58
53
  * })
59
- *
60
- * const MyAssistantLive = MyAssistant.toLayer((turn) =>
61
- * turn.loop({ instructions, skills: [orderingCampaigns] })
62
- * )
63
- * ```
64
- *
65
- * **Example** (Offering a skill only with the tools it uses)
66
- *
67
- * ```ts
68
- * import { Skill } from "effect-assistant"
69
- * import * as Tool from "effect/ai/Tool"
70
- * import * as Schema from "effect/Schema"
71
- *
72
- * const StartCampaign = Tool.make("start_campaign", {
73
- * parameters: Schema.Struct({ campaignId: Schema.String, budget: Schema.Finite }),
74
- * success: Schema.String
75
- * })
76
- *
77
- * // Left out of a loop whose toolkit has no start_campaign, like a viewer's.
78
- * const orderingCampaigns = Skill.make("Ordering a campaign", {
79
- * description: "Use when the user wants to order or start a campaign.",
80
- * instructions: "When you know who it should reach and the budget, call start_campaign.",
81
- * tools: [StartCampaign]
82
- * })
83
54
  * ```
84
55
  *
85
56
  * @category constructors
@@ -92,10 +63,8 @@ export const make = <R = never>(
92
63
  readonly description: string
93
64
  /** Text, or an Effect resolved each time the skill is loaded. */
94
65
  readonly instructions: Instructions.Instructions<R>
95
- /** The tools its instructions use: a loop whose toolkit lacks one leaves the skill out. */
96
- readonly tools?: ReadonlyArray<Tool.Any> | undefined
97
66
  }
98
- ): Skill<R> => ({ name, description: options.description, instructions: options.instructions, tools: options.tools })
67
+ ): Skill<R> => ({ name, description: options.description, instructions: options.instructions })
99
68
 
100
69
  /**
101
70
  * Extracts the services that skills need when they're loaded.
@@ -105,20 +74,128 @@ export const make = <R = never>(
105
74
  */
106
75
  export type Services<S> = S extends Skill<infer R> ? R : never
107
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
+
108
85
  /**
109
- * Returns the prompt with the skills' catalog appended, or the prompt unchanged when there are no
110
- * skills.
86
+ * Represents the `load_skill` tool, which reads a skill's instructions by name.
111
87
  *
112
- * @category utils
113
- * @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
114
198
  */
115
- export const withCatalog = <R>(prompt: string, skills: ReadonlyArray<Skill<R>>): string =>
116
- skills.length === 0
117
- ? prompt
118
- : [
119
- prompt,
120
- "",
121
- "## Skills",
122
- "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.",
123
- ...skills.map((skill) => `- ${skill.name}: ${skill.description}`)
124
- ].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,15 +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. */
129
- readonly toolkit?: Toolkit.Toolkit<T> | undefined
130
127
  /**
131
- * Playbooks, listed as a catalog in the prompt and loaded with `load_skill` when one matches. A
132
- * skill made with `tools` is left out when the toolkit lacks one of them.
128
+ * The loop's tools, as a native toolkit whose handlers come from the layer. Merge in a
129
+ * `Skill.toolkit(...)` for playbooks.
133
130
  */
134
- readonly skills?: ReadonlyArray<Skill<RS>> | undefined
131
+ readonly toolkit?: Toolkit.Toolkit<T> | undefined
135
132
  /** The model calls allowed per brief. Defaults to 8. */
136
133
  readonly maxSteps?: number | undefined
137
134
  }
@@ -152,13 +149,13 @@ export interface Run {
152
149
  * (follow-ups and steering). Read it before every model call, as the loop does.
153
150
  */
154
151
  readonly history: Effect.Effect<ReadonlyArray<Prompt.Message>>
155
- /** Runs the agent loop over the run's history, with the given instructions, tools, and skills. */
156
- readonly loop: <T extends Record<string, Tool.Any> = Record<never, never>, RS = never>(
157
- 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>
158
155
  ) => Stream.Stream<
159
156
  AssistantLoop.Event,
160
157
  AiError.AiError,
161
- LanguageModel.LanguageModel | AssistantLoop.ToolkitServices<T> | RS
158
+ LanguageModel.LanguageModel | AssistantLoop.ToolkitServices<T>
162
159
  >
163
160
  }
164
161
 
package/src/Timeline.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  * This module provides a chat's timeline: the chat as its user sees it. A chat client receives the
3
3
  * timeline and nothing else.
4
4
  *
5
- * A {@link Timeline} holds the chat's items: the messages with their blocks, and the app's own
6
- * events it shows (each typed with its event, see {@link EventItem}). It also holds the chat's
5
+ * A {@link Timeline} holds the chat's items: the messages with their blocks, the app's own events
6
+ * it shows (each typed with its event, see {@link EventItem}), and the tool calls waiting for the
7
+ * user's approval ({@link ApprovalItem}). It also holds the chat's
7
8
  * {@link Activity}, and whether the last turn failed or the chat was deleted. It never contains the
8
9
  * model's output, tool calls or results, what the model read, or why a turn failed. The server
9
10
  * streams {@link Change}s by piping the assistant's `subscribe` through {@link changes}, and the
@@ -20,6 +21,7 @@
20
21
  import { dual } from "effect/Function"
21
22
  import * as Match from "effect/Match"
22
23
  import * as Option from "effect/Option"
24
+ import * as Predicate from "effect/Predicate"
23
25
  import * as Schema from "effect/Schema"
24
26
  import * as SchemaTransformation from "effect/SchemaTransformation"
25
27
  import * as Stream from "effect/Stream"
@@ -29,6 +31,7 @@ import * as AssistantEvent from "./AssistantEvent.ts"
29
31
  import * as BlockKit from "./BlockKit.ts"
30
32
  import { InputId } from "./InputId.ts"
31
33
  import * as AppEvents from "./internal/events.ts"
34
+ import * as ToolApproval from "./ToolApproval.ts"
32
35
  import { toJson } from "./internal/schema.ts"
33
36
 
34
37
  /**
@@ -61,7 +64,9 @@ export const Block = Schema.Struct({
61
64
  kind: Schema.String,
62
65
  props: Schema.Json,
63
66
  status: BlockStatus,
64
- answer: Schema.optional(Schema.Json)
67
+ answer: Schema.optional(Schema.Json),
68
+ /** For a block that shows a tool's result (`Block.fromToolResult`): the result, as the tool's success schema encodes it. */
69
+ result: Schema.optional(Schema.Json)
65
70
  })
66
71
 
67
72
  /**
@@ -135,8 +140,43 @@ export type EventItem<E> = E extends { readonly _tag: infer Tag extends string }
135
140
  : never
136
141
 
137
142
  /**
138
- * Represents an item in the chat's timeline: a message the user or the assistant sent, or one of
139
- * the app's own events it shows (see {@link EventItem}).
143
+ * Schema for a tool call that waits, or waited, for the user's approval, as an item on the
144
+ * timeline.
145
+ *
146
+ * **Details**
147
+ *
148
+ * It is only the yes or no: the assistant's own message tells the user what it asks them to
149
+ * approve, so the item carries no params. `id` is the call's id, which the decision is for (see
150
+ * `ToolApproval.approve`), and `tool` the tool's name, for a label. `status` is where the call stands
151
+ * (`ToolApproval.Status`); `reason` is the user's, when they rejected it with one; `outcome` is how
152
+ * the approved call went, once it ran. `at` is when the call started waiting, in epoch
153
+ * milliseconds. A change updates the item in place as the call is decided and run.
154
+ *
155
+ * @category schemas
156
+ * @since 0.4.0
157
+ */
158
+ export const ApprovalItem = Schema.TaggedStruct("ToolApproval", {
159
+ id: Schema.String,
160
+ tool: Schema.String,
161
+ status: Schema.Literals(["pending", "approved", "rejected", "expired"]),
162
+ reason: Schema.optional(Schema.String),
163
+ outcome: Schema.optional(AssistantEvent.ToolOutcome),
164
+ at: Schema.Finite
165
+ })
166
+
167
+ /**
168
+ * Represents a tool call that waits, or waited, for the user's approval, as an item on the
169
+ * timeline.
170
+ *
171
+ * @category models
172
+ * @since 0.4.0
173
+ */
174
+ export type ApprovalItem = typeof ApprovalItem.Type
175
+
176
+ /**
177
+ * Represents an item in the chat's timeline: a message the user or the assistant sent, one of the
178
+ * app's own events it shows (see {@link EventItem}), or a tool call waiting for the user's approval
179
+ * (see {@link ApprovalItem}).
140
180
  *
141
181
  * **Example** (Showing each item of a chat)
142
182
  *
@@ -158,7 +198,7 @@ export type EventItem<E> = E extends { readonly _tag: infer Tag extends string }
158
198
  * @category models
159
199
  * @since 0.3.0
160
200
  */
161
- export type Item<Events = never> = UserMessage | AssistantMessage | EventItem<Events>
201
+ export type Item<Events = never> = UserMessage | AssistantMessage | EventItem<Events> | ApprovalItem
162
202
 
163
203
  /**
164
204
  * Schema for a specialist that is working for the assistant. It holds the specialist's name and
@@ -227,7 +267,7 @@ export interface Timeline<Events = never> {
227
267
  * **Details**
228
268
  *
229
269
  * A change holds each item that is new or changed (for example, a message whose block was
230
- * answered or closed), in full. It also holds the current activity, whether the last turn failed,
270
+ * answered or closed, or a call the user approved), in full. It also holds the current activity, whether the last turn failed,
231
271
  * and whether the chat was deleted. `seq` is the position in the chat's log that the change is up
232
272
  * to.
233
273
  *
@@ -250,7 +290,10 @@ function makeChange<const Events extends ReadonlyArray<AssistantEvent.AppEventSc
250
290
  ): Schema.Codec<Change<Events[number]["Type"]>, Schema.Json>
251
291
  // The overload states the decoded type: an item per given schema, each typed with its own event.
252
292
  function makeChange(events: ReadonlyArray<AssistantEvent.AppEventSchema> = []): Schema.Top {
253
- const AnyItem = Schema.toCodecJson(Schema.Union([UserMessage, AssistantMessage, ...events.map(eventItem)]))
293
+ const AnyItem = Schema.toCodecJson(
294
+ Schema.Union([UserMessage, AssistantMessage, ApprovalItem, ...events.map(eventItem)])
295
+ )
296
+
254
297
  const decodeItem = Schema.decodeUnknownOption(AnyItem)
255
298
  const encodeItem = Schema.encodeUnknownSync(AnyItem)
256
299
 
@@ -280,7 +323,8 @@ function makeChange(events: ReadonlyArray<AssistantEvent.AppEventSchema> = []):
280
323
  * **Details**
281
324
  *
282
325
  * It is the success schema of the RPC that streams a chat's timeline: name the events the chat's
283
- * users see, the same ones the handler passes to {@link changes} as `show`. Each item decodes its
326
+ * users see, the same ones the handler passes to {@link changes} as `show`. Tool calls waiting for
327
+ * approval are always in it, as {@link ApprovalItem}s. Each item decodes its
284
328
  * event with that event's schema, so the client gets `Change<Events>` typed with them and folds it
285
329
  * with {@link fold}, no definition needed. `Change()` carries no app events.
286
330
  *
@@ -417,10 +461,12 @@ interface AnyEventItem {
417
461
  readonly event: { readonly _tag: string }
418
462
  }
419
463
 
464
+ type AnyItem = UserMessage | AssistantMessage | AnyEventItem | ApprovalItem
465
+
420
466
  // What the fold below builds: the overloads that use it type its items with the events shown.
421
467
  interface AnyChange {
422
468
  readonly seq: number
423
- readonly items: ReadonlyArray<UserMessage | AssistantMessage | AnyEventItem>
469
+ readonly items: ReadonlyArray<AnyItem>
424
470
  readonly activity: Activity
425
471
  readonly failed: boolean
426
472
  readonly deleted: boolean
@@ -575,14 +621,17 @@ const sameActivity = (a: Activity, b: Activity) =>
575
621
 
576
622
  const folding = (show: ReadonlyArray<AssistantEvent.AppEventSchema>): Folding => {
577
623
  const shown = new Map(show.map((schema) => [AppEvents.tagOf(schema), schema]))
624
+ let approvals: ReadonlyMap<string, ToolApproval.State> = new Map()
578
625
  let seq = 0
579
626
  let failed = false
580
627
  let deleted = false
581
628
  let activity = AssistantActivity.empty
582
629
  let blocks: ReadonlyMap<string, BlockKit.State> = new Map()
583
630
  // The items in order, and the assistant's messages as stored: their blocks change as the chat goes on.
584
- const items = new Map<string, UserMessage | AssistantMessage | AnyEventItem>()
631
+ const items = new Map<string, AnyItem>()
585
632
  const sent = new Map<string, AssistantEvent.AssistantMessage>()
633
+ // The results a block can show, by id: a message shows one stored before it.
634
+ const results = new Map<string, Schema.Json>()
586
635
 
587
636
  const blockOf = (message: AssistantEvent.AssistantMessage): Block | undefined => {
588
637
  if (message.block === undefined) return undefined
@@ -590,8 +639,11 @@ const folding = (show: ReadonlyArray<AssistantEvent.AppEventSchema>): Folding =>
590
639
  const state = blocks.get(message.id)
591
640
  const status = state === undefined || state.open ? "open" : state.answer === undefined ? "closed" : "answered"
592
641
  const block: Block = { kind: message.block.kind, props: toJson(message.block.props), status }
642
+ const { resultId } = message.block
643
+ const result = resultId === undefined ? undefined : results.get(resultId)
644
+ const shown = result === undefined ? block : { ...block, result }
593
645
 
594
- return state?.answer === undefined ? block : { ...block, answer: toJson(state.answer.value) }
646
+ return state?.answer === undefined ? shown : { ...shown, answer: toJson(state.answer.value) }
595
647
  }
596
648
 
597
649
  const itemOf = (message: AssistantEvent.AssistantMessage, at: number): AssistantMessage => {
@@ -634,6 +686,13 @@ const folding = (show: ReadonlyArray<AssistantEvent.AppEventSchema>): Folding =>
634
686
  return [answer.messageId]
635
687
  },
636
688
  BlocksClosed: (closed) => closed.messageIds,
689
+ ApprovalRequested: () => [],
690
+ ApprovalDecision: () => {
691
+ failed = false
692
+
693
+ return []
694
+ },
695
+ ApprovalsExpired: () => [],
637
696
  TurnFailed: () => {
638
697
  failed = true
639
698
 
@@ -641,7 +700,13 @@ const folding = (show: ReadonlyArray<AssistantEvent.AppEventSchema>): Folding =>
641
700
  },
642
701
  ModelOutput: () => [],
643
702
  ToolStarted: () => [],
644
- ToolResult: () => [],
703
+ ToolResult: (result) => {
704
+ if (result.resultId !== undefined && result.outcome === "success") {
705
+ results.set(result.resultId, result.full ?? result.result)
706
+ }
707
+
708
+ return []
709
+ },
645
710
  TurnStarted: () => [],
646
711
  TurnCompleted: () => [],
647
712
  SpecialistStarted: () => [],
@@ -670,8 +735,38 @@ const folding = (show: ReadonlyArray<AssistantEvent.AppEventSchema>): Folding =>
670
735
  }))
671
736
  }
672
737
 
738
+ const callsOf = (event: AssistantEvent.AssistantEvent): ReadonlyArray<string> =>
739
+ Predicate.isTagged(event, "ApprovalsExpired")
740
+ ? event.callIds
741
+ : Predicate.isTagged(event, "ApprovalRequested") ||
742
+ Predicate.isTagged(event, "ApprovalDecision") ||
743
+ Predicate.isTagged(event, "ToolResult")
744
+ ? [event.callId]
745
+ : []
746
+
747
+ // The items of the calls this entry changed.
748
+ const approvalItems = (event: AssistantEvent.AssistantEvent, before: ReadonlyMap<string, ToolApproval.State>) =>
749
+ callsOf(event).flatMap((callId) => {
750
+ const state = approvals.get(callId)
751
+
752
+ if (state === undefined || state === before.get(callId)) return []
753
+
754
+ const item = ApprovalItem.make({
755
+ id: callId,
756
+ tool: state.request.tool,
757
+ status: state.status,
758
+ reason: state.reason,
759
+ outcome: state.outcome,
760
+ at: state.at
761
+ })
762
+
763
+ items.set(callId, item)
764
+
765
+ return [item]
766
+ })
767
+
673
768
  const step = (entry: AssistantEvent.AnyEntry): AnyChange | undefined => {
674
- const before = { activity: activityOf(activity), failed, deleted }
769
+ const previous = { activity: activityOf(activity), failed, deleted }
675
770
  const { event } = entry
676
771
 
677
772
  seq = entry.seq
@@ -682,7 +777,7 @@ const folding = (show: ReadonlyArray<AssistantEvent.AppEventSchema>): Folding =>
682
777
 
683
778
  return Option.match(eventItemOf(entry), {
684
779
  onNone: () =>
685
- sameActivity(before.activity, now) ? undefined : { seq, items: [], activity: now, failed, deleted },
780
+ sameActivity(previous.activity, now) ? undefined : { seq, items: [], activity: now, failed, deleted },
686
781
  onSome: (item) => {
687
782
  items.set(item.id, item)
688
783
 
@@ -693,6 +788,9 @@ const folding = (show: ReadonlyArray<AssistantEvent.AppEventSchema>): Folding =>
693
788
 
694
789
  blocks = BlockKit.apply(blocks, event)
695
790
 
791
+ const decidedBefore = approvals
792
+ approvals = ToolApproval.apply(approvals, entry)
793
+
696
794
  const changed = [...new Set(touched(event, entry.at))].flatMap((id) => {
697
795
  const message = sent.get(id)
698
796
  const current = items.get(id)
@@ -705,14 +803,15 @@ const folding = (show: ReadonlyArray<AssistantEvent.AppEventSchema>): Folding =>
705
803
  return [next]
706
804
  })
707
805
 
806
+ const all = [...changed, ...approvalItems(event, decidedBefore)]
708
807
  const now = activityOf(activity)
709
808
 
710
- return changed.length === 0 &&
711
- failed === before.failed &&
712
- deleted === before.deleted &&
713
- sameActivity(before.activity, now)
809
+ return all.length === 0 &&
810
+ failed === previous.failed &&
811
+ deleted === previous.deleted &&
812
+ sameActivity(previous.activity, now)
714
813
  ? undefined
715
- : { seq, items: changed, activity: now, failed, deleted }
814
+ : { seq, items: all, activity: now, failed, deleted }
716
815
  }
717
816
 
718
817
  return {
@@ -728,7 +827,7 @@ const merge = (changes: ReadonlyArray<AnyChange>): AnyChange | undefined => {
728
827
 
729
828
  if (last === undefined) return undefined
730
829
 
731
- const items = new Map<string, UserMessage | AssistantMessage | AnyEventItem>()
830
+ const items = new Map<string, AnyItem>()
732
831
 
733
832
  for (const change of changes) for (const item of change.items) items.set(item.id, item)
734
833