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.
- package/dist/Assistant.d.ts +5 -11
- package/dist/Assistant.d.ts.map +1 -1
- package/dist/Assistant.js.map +1 -1
- package/dist/AssistantEvent.d.ts +66 -7
- package/dist/AssistantEvent.d.ts.map +1 -1
- package/dist/AssistantEvent.js +74 -9
- package/dist/AssistantEvent.js.map +1 -1
- package/dist/AssistantLoop.d.ts +4 -2
- package/dist/AssistantLoop.d.ts.map +1 -1
- package/dist/AssistantLoop.js +4 -2
- package/dist/AssistantLoop.js.map +1 -1
- package/dist/Block.d.ts +63 -1
- package/dist/Block.d.ts.map +1 -1
- package/dist/Block.js +63 -2
- package/dist/Block.js.map +1 -1
- package/dist/BlockKit.d.ts +25 -3
- package/dist/BlockKit.d.ts.map +1 -1
- package/dist/BlockKit.js +14 -12
- package/dist/BlockKit.js.map +1 -1
- package/dist/Compaction.d.ts +1 -1
- package/dist/Compaction.d.ts.map +1 -1
- package/dist/Compaction.js +11 -4
- package/dist/Compaction.js.map +1 -1
- package/dist/Skill.d.ts +85 -43
- package/dist/Skill.d.ts.map +1 -1
- package/dist/Skill.js +93 -44
- package/dist/Skill.js.map +1 -1
- package/dist/Specialist.d.ts +6 -9
- package/dist/Specialist.d.ts.map +1 -1
- package/dist/Specialist.js.map +1 -1
- package/dist/Timeline.d.ts +44 -6
- package/dist/Timeline.d.ts.map +1 -1
- package/dist/Timeline.js +87 -16
- package/dist/Timeline.js.map +1 -1
- package/dist/ToolApproval.d.ts +146 -0
- package/dist/ToolApproval.d.ts.map +1 -0
- package/dist/ToolApproval.js +201 -0
- package/dist/ToolApproval.js.map +1 -0
- package/dist/ToolDiscovery.d.ts +6 -4
- package/dist/ToolDiscovery.d.ts.map +1 -1
- package/dist/ToolDiscovery.js +5 -4
- package/dist/ToolDiscovery.js.map +1 -1
- package/dist/ToolResult.d.ts +56 -0
- package/dist/ToolResult.d.ts.map +1 -0
- package/dist/ToolResult.js +49 -0
- package/dist/ToolResult.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +6 -4
- package/dist/internal/chat.js +4 -1
- package/dist/internal/chat.js.map +1 -1
- package/dist/internal/history.js +28 -3
- package/dist/internal/history.js.map +1 -1
- package/dist/internal/loop.d.ts +10 -5
- package/dist/internal/loop.d.ts.map +1 -1
- package/dist/internal/loop.js +60 -21
- package/dist/internal/loop.js.map +1 -1
- package/dist/internal/specialists.js +9 -16
- package/dist/internal/specialists.js.map +1 -1
- package/dist/internal/toolkit.d.ts +11 -7
- package/dist/internal/toolkit.d.ts.map +1 -1
- package/dist/internal/toolkit.js +74 -21
- package/dist/internal/toolkit.js.map +1 -1
- package/dist/internal/tools.js +39 -6
- package/dist/internal/tools.js.map +1 -1
- package/dist/internal/turns.d.ts.map +1 -1
- package/dist/internal/turns.js +24 -16
- package/dist/internal/turns.js.map +1 -1
- package/package.json +1 -1
- package/src/Assistant.ts +7 -13
- package/src/AssistantEvent.ts +73 -8
- package/src/AssistantLoop.ts +4 -2
- package/src/Block.ts +82 -1
- package/src/BlockKit.ts +37 -4
- package/src/Compaction.ts +19 -2
- package/src/Skill.ts +134 -57
- package/src/Specialist.ts +8 -11
- package/src/Timeline.ts +120 -21
- package/src/ToolApproval.ts +249 -0
- package/src/ToolDiscovery.ts +8 -3
- package/src/ToolResult.ts +80 -0
- package/src/index.ts +10 -0
- package/src/internal/chat.ts +11 -1
- package/src/internal/history.ts +58 -3
- package/src/internal/loop.ts +138 -35
- package/src/internal/specialists.ts +7 -13
- package/src/internal/toolkit.ts +102 -35
- package/src/internal/tools.ts +81 -5
- package/src/internal/turns.ts +31 -10
- package/dist/internal/skills.js +0 -33
- package/dist/internal/skills.js.map +0 -1
- 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,
|
|
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
|
|
5
|
-
*
|
|
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
|
|
11
|
-
import
|
|
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
|
|
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
|
-
*
|
|
110
|
-
* skills.
|
|
86
|
+
* Represents the `load_skill` tool, which reads a skill's instructions by name.
|
|
111
87
|
*
|
|
112
|
-
* @category
|
|
113
|
-
* @since 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
|
|
116
|
-
skills
|
|
117
|
-
|
|
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
|
|
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
|
-
*
|
|
132
|
-
*
|
|
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
|
|
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
|
|
156
|
-
readonly loop: <T extends Record<string, Tool.Any> = Record<never, never
|
|
157
|
-
options: LoopOptions<T
|
|
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>
|
|
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,
|
|
6
|
-
*
|
|
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
|
-
*
|
|
139
|
-
*
|
|
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(
|
|
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`.
|
|
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<
|
|
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,
|
|
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 ?
|
|
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
|
|
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(
|
|
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
|
|
711
|
-
failed ===
|
|
712
|
-
deleted ===
|
|
713
|
-
sameActivity(
|
|
809
|
+
return all.length === 0 &&
|
|
810
|
+
failed === previous.failed &&
|
|
811
|
+
deleted === previous.deleted &&
|
|
812
|
+
sameActivity(previous.activity, now)
|
|
714
813
|
? undefined
|
|
715
|
-
: { seq, items:
|
|
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,
|
|
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
|
|