effect-assistant 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +22 -2
- package/dist/AppEvent.d.ts +111 -0
- package/dist/AppEvent.d.ts.map +1 -0
- package/dist/AppEvent.js +71 -0
- package/dist/AppEvent.js.map +1 -0
- package/dist/Assistant.d.ts +403 -0
- package/dist/Assistant.d.ts.map +1 -0
- package/dist/Assistant.js +126 -0
- package/dist/Assistant.js.map +1 -0
- package/dist/AssistantActivity.d.ts +85 -0
- package/dist/AssistantActivity.d.ts.map +1 -0
- package/dist/AssistantActivity.js +105 -0
- package/dist/AssistantActivity.js.map +1 -0
- package/dist/AssistantEngine.d.ts +116 -0
- package/dist/AssistantEngine.d.ts.map +1 -0
- package/dist/AssistantEngine.js +255 -0
- package/dist/AssistantEngine.js.map +1 -0
- package/dist/AssistantEvent.d.ts +551 -0
- package/dist/AssistantEvent.d.ts.map +1 -0
- package/dist/AssistantEvent.js +473 -0
- package/dist/AssistantEvent.js.map +1 -0
- package/dist/AssistantLoop.d.ts +173 -0
- package/dist/AssistantLoop.d.ts.map +1 -0
- package/dist/AssistantLoop.js +111 -0
- package/dist/AssistantLoop.js.map +1 -0
- package/dist/AssistantToolCall.d.ts +32 -0
- package/dist/AssistantToolCall.d.ts.map +1 -0
- package/dist/AssistantToolCall.js +37 -0
- package/dist/AssistantToolCall.js.map +1 -0
- package/dist/Block.d.ts +69 -0
- package/dist/Block.d.ts.map +1 -0
- package/dist/Block.js +39 -0
- package/dist/Block.js.map +1 -0
- package/dist/BlockKit.d.ts +180 -0
- package/dist/BlockKit.d.ts.map +1 -0
- package/dist/BlockKit.js +148 -0
- package/dist/BlockKit.js.map +1 -0
- package/dist/ChatId.d.ts +31 -0
- package/dist/ChatId.d.ts.map +1 -0
- package/dist/ChatId.js +36 -0
- package/dist/ChatId.js.map +1 -0
- package/dist/ChatLog.d.ts +109 -0
- package/dist/ChatLog.d.ts.map +1 -0
- package/dist/ChatLog.js +177 -0
- package/dist/ChatLog.js.map +1 -0
- package/dist/ChatView.d.ts +408 -0
- package/dist/ChatView.d.ts.map +1 -0
- package/dist/ChatView.js +443 -0
- package/dist/ChatView.js.map +1 -0
- package/dist/Chats.d.ts +110 -0
- package/dist/Chats.d.ts.map +1 -0
- package/dist/Chats.js +98 -0
- package/dist/Chats.js.map +1 -0
- package/dist/Compaction.d.ts +105 -0
- package/dist/Compaction.d.ts.map +1 -0
- package/dist/Compaction.js +126 -0
- package/dist/Compaction.js.map +1 -0
- package/dist/CurrentChat.d.ts +40 -0
- package/dist/CurrentChat.d.ts.map +1 -0
- package/dist/CurrentChat.js +33 -0
- package/dist/CurrentChat.js.map +1 -0
- package/dist/InputId.d.ts +38 -0
- package/dist/InputId.d.ts.map +1 -0
- package/dist/InputId.js +43 -0
- package/dist/InputId.js.map +1 -0
- package/dist/Instructions.d.ts +56 -0
- package/dist/Instructions.d.ts.map +1 -0
- package/dist/Instructions.js +50 -0
- package/dist/Instructions.js.map +1 -0
- package/dist/Memory.d.ts +190 -0
- package/dist/Memory.d.ts.map +1 -0
- package/dist/Memory.js +216 -0
- package/dist/Memory.js.map +1 -0
- package/dist/RunId.d.ts +41 -0
- package/dist/RunId.d.ts.map +1 -0
- package/dist/RunId.js +46 -0
- package/dist/RunId.js.map +1 -0
- package/dist/Skill.d.ts +72 -0
- package/dist/Skill.d.ts.map +1 -0
- package/dist/Skill.js +58 -0
- package/dist/Skill.js.map +1 -0
- package/dist/Specialist.d.ts +267 -0
- package/dist/Specialist.d.ts.map +1 -0
- package/dist/Specialist.js +119 -0
- package/dist/Specialist.js.map +1 -0
- package/dist/ToolDiscovery.d.ts +107 -0
- package/dist/ToolDiscovery.d.ts.map +1 -0
- package/dist/ToolDiscovery.js +140 -0
- package/dist/ToolDiscovery.js.map +1 -0
- package/dist/ToolFailure.d.ts +66 -0
- package/dist/ToolFailure.d.ts.map +1 -0
- package/dist/ToolFailure.js +79 -0
- package/dist/ToolFailure.js.map +1 -0
- package/dist/_virtual/_rolldown/runtime.js +13 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +24 -0
- package/dist/internal/chat.js +177 -0
- package/dist/internal/chat.js.map +1 -0
- package/dist/internal/events.d.ts +46 -0
- package/dist/internal/events.d.ts.map +1 -0
- package/dist/internal/events.js +65 -0
- package/dist/internal/events.js.map +1 -0
- package/dist/internal/history.js +147 -0
- package/dist/internal/history.js.map +1 -0
- package/dist/internal/instructions.js +11 -0
- package/dist/internal/instructions.js.map +1 -0
- package/dist/internal/loop.d.ts +73 -0
- package/dist/internal/loop.d.ts.map +1 -0
- package/dist/internal/loop.js +131 -0
- package/dist/internal/loop.js.map +1 -0
- package/dist/internal/schema.js +14 -0
- package/dist/internal/schema.js.map +1 -0
- package/dist/internal/skills.js +28 -0
- package/dist/internal/skills.js.map +1 -0
- package/dist/internal/specialists.js +272 -0
- package/dist/internal/specialists.js.map +1 -0
- package/dist/internal/telemetry.js +85 -0
- package/dist/internal/telemetry.js.map +1 -0
- package/dist/internal/toolkit.d.ts +62 -0
- package/dist/internal/toolkit.d.ts.map +1 -0
- package/dist/internal/toolkit.js +108 -0
- package/dist/internal/toolkit.js.map +1 -0
- package/dist/internal/tools.js +113 -0
- package/dist/internal/tools.js.map +1 -0
- package/dist/internal/turns.d.ts +64 -0
- package/dist/internal/turns.d.ts.map +1 -0
- package/dist/internal/turns.js +214 -0
- package/dist/internal/turns.js.map +1 -0
- package/dist/internal/uuid.js +45 -0
- package/dist/internal/uuid.js.map +1 -0
- package/dist/internal/writer.d.ts +12 -0
- package/dist/internal/writer.d.ts.map +1 -0
- package/package.json +52 -3
- package/src/AppEvent.ts +134 -0
- package/src/Assistant.ts +589 -0
- package/src/AssistantActivity.ts +150 -0
- package/src/AssistantEngine.ts +343 -0
- package/src/AssistantEvent.ts +569 -0
- package/src/AssistantLoop.ts +209 -0
- package/src/AssistantToolCall.ts +40 -0
- package/src/Block.ts +84 -0
- package/src/BlockKit.ts +298 -0
- package/src/ChatId.ts +36 -0
- package/src/ChatLog.ts +247 -0
- package/src/ChatView.ts +587 -0
- package/src/Chats.ts +122 -0
- package/src/Compaction.ts +207 -0
- package/src/CurrentChat.ts +47 -0
- package/src/InputId.ts +43 -0
- package/src/Instructions.ts +72 -0
- package/src/Memory.ts +276 -0
- package/src/RunId.ts +46 -0
- package/src/Skill.ts +91 -0
- package/src/Specialist.ts +355 -0
- package/src/ToolDiscovery.ts +179 -0
- package/src/ToolFailure.ts +74 -0
- package/src/index.ts +120 -0
- package/src/internal/chat.ts +323 -0
- package/src/internal/events.ts +100 -0
- package/src/internal/history.ts +294 -0
- package/src/internal/instructions.ts +9 -0
- package/src/internal/loop.ts +274 -0
- package/src/internal/schema.ts +25 -0
- package/src/internal/skills.ts +37 -0
- package/src/internal/specialists.ts +410 -0
- package/src/internal/telemetry.ts +125 -0
- package/src/internal/toolkit.ts +188 -0
- package/src/internal/tools.ts +159 -0
- package/src/internal/turns.ts +410 -0
- package/src/internal/uuid.ts +50 -0
- package/src/internal/writer.ts +12 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import * as Prompt from "effect/ai/Prompt"
|
|
2
|
+
import * as Response from "effect/ai/Response"
|
|
3
|
+
import * as Match from "effect/Match"
|
|
4
|
+
import * as Option from "effect/Option"
|
|
5
|
+
import * as Predicate from "effect/Predicate"
|
|
6
|
+
import * as Schema from "effect/Schema"
|
|
7
|
+
import * as AssistantEvent from "../AssistantEvent.ts"
|
|
8
|
+
import { toJson, toJsonText } from "./schema.ts"
|
|
9
|
+
import * as Uuid from "./uuid.ts"
|
|
10
|
+
|
|
11
|
+
/** @internal */
|
|
12
|
+
export const userText = (text: string): Prompt.UserMessage =>
|
|
13
|
+
Prompt.userMessage({ content: [Prompt.textPart({ text })] })
|
|
14
|
+
|
|
15
|
+
/** @internal */
|
|
16
|
+
export const toolMessage = (result: AssistantEvent.ToolResult): Prompt.ToolMessage =>
|
|
17
|
+
Prompt.toolMessage({
|
|
18
|
+
content: [
|
|
19
|
+
Prompt.toolResultPart({
|
|
20
|
+
id: result.callId,
|
|
21
|
+
name: result.tool,
|
|
22
|
+
isFailure: result.outcome === "failure",
|
|
23
|
+
result: result.result,
|
|
24
|
+
providerExecuted: false
|
|
25
|
+
})
|
|
26
|
+
]
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// A call with no result is one a crash cut off.
|
|
30
|
+
/** @internal */
|
|
31
|
+
export const unansweredCalls = (history: ReadonlyArray<Prompt.Message>): ReadonlyArray<AssistantEvent.ToolCallPart> => {
|
|
32
|
+
const answered = new Set(
|
|
33
|
+
history.flatMap((message) =>
|
|
34
|
+
message.role === "tool" ? message.content.flatMap((part) => (part.type === "tool-result" ? [part.id] : [])) : []
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
return history.flatMap((message) =>
|
|
39
|
+
message.role === "assistant"
|
|
40
|
+
? message.content.flatMap((part) =>
|
|
41
|
+
part.type === "tool-call" && !part.providerExecuted && !answered.has(part.id)
|
|
42
|
+
? [
|
|
43
|
+
Response.makePart("tool-call", {
|
|
44
|
+
id: part.id,
|
|
45
|
+
name: part.name,
|
|
46
|
+
params: toJson(part.params),
|
|
47
|
+
providerExecuted: false,
|
|
48
|
+
metadata: part.options
|
|
49
|
+
})
|
|
50
|
+
]
|
|
51
|
+
: []
|
|
52
|
+
)
|
|
53
|
+
: []
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @internal */
|
|
58
|
+
export const notRunOutput = "Not run: the turn ended before this call ran."
|
|
59
|
+
|
|
60
|
+
/** @internal */
|
|
61
|
+
export const mayHaveRunOutput =
|
|
62
|
+
"Stopped: the turn ended while this call ran. It may have done its work; check before doing it again."
|
|
63
|
+
|
|
64
|
+
const ago = (millis: number) => {
|
|
65
|
+
const seconds = Math.max(0, Math.round(millis / 1000))
|
|
66
|
+
|
|
67
|
+
return seconds < 90 ? `${seconds} seconds` : `${Math.round(seconds / 60)} minutes`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const finishedText = (
|
|
71
|
+
entries: ReadonlyArray<AssistantEvent.AnyEntry>,
|
|
72
|
+
index: number,
|
|
73
|
+
finished: AssistantEvent.SpecialistFinished
|
|
74
|
+
) => {
|
|
75
|
+
const at = entries[index]!
|
|
76
|
+
|
|
77
|
+
const isStart = ({ event }: AssistantEvent.AnyEntry) =>
|
|
78
|
+
AssistantEvent.isLibraryEvent(event) &&
|
|
79
|
+
Predicate.isTagged(event, "SpecialistStarted") &&
|
|
80
|
+
event.runId === finished.runId
|
|
81
|
+
|
|
82
|
+
const from = entries.slice(0, index).findLastIndex(isStart)
|
|
83
|
+
const started = from === -1 ? undefined : entries[from]
|
|
84
|
+
const since = started === undefined ? [] : entries.slice(from + 1, index)
|
|
85
|
+
const wrote = since.some(({ event }) => Predicate.isTagged(event, "UserMessage"))
|
|
86
|
+
|
|
87
|
+
const lines = [
|
|
88
|
+
"A report from a specialist you started. The user hasn't seen it.",
|
|
89
|
+
started === undefined
|
|
90
|
+
? `You started it for: ${finished.goal}`
|
|
91
|
+
: `You started it ${ago(at.at - started.at)} ago for: ${finished.goal}`,
|
|
92
|
+
wrote ? "The user has written since you started it." : "The user hasn't written since you started it.",
|
|
93
|
+
Match.value(finished.status).pipe(
|
|
94
|
+
Match.when("answered", () => `Report:\n${finished.report ?? ""}`),
|
|
95
|
+
Match.when("failed", () => `It failed: ${finished.error ?? ""}`),
|
|
96
|
+
Match.when("cut_off", () => "A restart cut it off before it finished. Spawn it again if it's still needed."),
|
|
97
|
+
Match.when("stopped", () => "You stopped it."),
|
|
98
|
+
Match.exhaustive
|
|
99
|
+
)
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
return `<specialist name="${finished.specialist}" run="${finished.runId}" status="${finished.status}">\n${lines.join(
|
|
103
|
+
"\n"
|
|
104
|
+
)}\n</specialist>`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const answerText = (answer: AssistantEvent.BlockAnswer) =>
|
|
108
|
+
`<answer message="${answer.messageId}" kind="${answer.kind}">\nThe user answered with the block on your message ${answer.messageId}.\n${toJsonText(answer.value)}\n</answer>`
|
|
109
|
+
|
|
110
|
+
const resultText = (result: AssistantEvent.ToolResult) =>
|
|
111
|
+
`<result tool="${result.tool}" call="${result.callId}" outcome="${result.outcome}">\nThe result of a call from earlier in this chat.\n${toJsonText(result.result)}\n</result>`
|
|
112
|
+
|
|
113
|
+
// In the order the model saw the entries, not the order they were stored.
|
|
114
|
+
/** @internal */
|
|
115
|
+
export const fromEntries = (
|
|
116
|
+
entries: ReadonlyArray<AssistantEvent.AnyEntry>,
|
|
117
|
+
readApp: (event: { readonly _tag: string }) => string | undefined
|
|
118
|
+
): ReadonlyArray<Prompt.Message> => {
|
|
119
|
+
const history: Array<Prompt.Message> = []
|
|
120
|
+
const held: Array<Prompt.Message> = []
|
|
121
|
+
const open = new Set<string>()
|
|
122
|
+
const called = new Set<string>()
|
|
123
|
+
|
|
124
|
+
const flushIfClosed = () => {
|
|
125
|
+
if (open.size === 0) history.push(...held.splice(0))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const nextSeen = (from: number) => {
|
|
129
|
+
const next = entries.slice(from + 1).find(({ event }) => Predicate.isTagged(event, "ModelOutput"))?.event
|
|
130
|
+
|
|
131
|
+
return next !== undefined && AssistantEvent.isLibraryEvent(next) && Predicate.isTagged(next, "ModelOutput")
|
|
132
|
+
? next.seenThroughSeq
|
|
133
|
+
: undefined
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const input = (index: number, item: Prompt.Message) => {
|
|
137
|
+
const seen = nextSeen(index)
|
|
138
|
+
|
|
139
|
+
if (open.size > 0 || (seen !== undefined && seen < entries[index]!.seq)) held.push(item)
|
|
140
|
+
else history.push(item)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
entries.forEach(({ event }, index) => {
|
|
144
|
+
if (!AssistantEvent.isLibraryEvent(event)) {
|
|
145
|
+
const text = readApp(event)
|
|
146
|
+
|
|
147
|
+
if (text !== undefined) input(index, userText(text))
|
|
148
|
+
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
Match.value(event).pipe(
|
|
153
|
+
Match.tagsExhaustive({
|
|
154
|
+
UserMessage: (message) =>
|
|
155
|
+
input(index, userText(AssistantEvent.textWithAttachments(message.text, message.attachments))),
|
|
156
|
+
BlockAnswer: (answer) => input(index, userText(answerText(answer))),
|
|
157
|
+
ModelOutput: (output) => {
|
|
158
|
+
history.push(...Prompt.fromResponseParts(output.parts).content)
|
|
159
|
+
|
|
160
|
+
for (const part of output.parts) {
|
|
161
|
+
if (part.type === "tool-call") {
|
|
162
|
+
open.add(part.id)
|
|
163
|
+
called.add(part.id)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
flushIfClosed()
|
|
168
|
+
},
|
|
169
|
+
ToolResult: (result) => {
|
|
170
|
+
// A view that starts after the call (since a summary, say) keeps its result as text: a tool result needs its
|
|
171
|
+
// call right before it.
|
|
172
|
+
if (!called.has(result.callId)) return input(index, userText(resultText(result)))
|
|
173
|
+
|
|
174
|
+
history.push(toolMessage(result))
|
|
175
|
+
open.delete(result.callId)
|
|
176
|
+
flushIfClosed()
|
|
177
|
+
},
|
|
178
|
+
AssistantMessage: () => undefined,
|
|
179
|
+
ToolStarted: () => undefined,
|
|
180
|
+
TurnStarted: () => undefined,
|
|
181
|
+
TurnCompleted: () => undefined,
|
|
182
|
+
TurnFailed: () => undefined,
|
|
183
|
+
SpecialistFinished: (finished) => input(index, userText(finishedText(entries, index, finished))),
|
|
184
|
+
// Every run starts in a call of the model's own (a spawn or a follow-up), whose result says so.
|
|
185
|
+
SpecialistStarted: () => undefined,
|
|
186
|
+
BlocksClosed: () => undefined,
|
|
187
|
+
ChatDeleted: () => undefined
|
|
188
|
+
})
|
|
189
|
+
)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
return [...history, ...held]
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** @internal */
|
|
196
|
+
export const isInput = (event: { readonly _tag: string }, wakes: ReadonlySet<string>): boolean =>
|
|
197
|
+
AssistantEvent.isInput(event) || (!AssistantEvent.isLibraryEvent(event) && wakes.has(event._tag))
|
|
198
|
+
|
|
199
|
+
/** @internal */
|
|
200
|
+
export const handledThrough = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): number => {
|
|
201
|
+
const last = entries.findLast(({ event }) => AssistantEvent.isTurnEnd(event))?.event
|
|
202
|
+
|
|
203
|
+
return last !== undefined && AssistantEvent.isTurnEnd(last) ? last.throughSeq : 0
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// A run the assistant stopped isn't an input here: it wakes nothing.
|
|
207
|
+
/** @internal */
|
|
208
|
+
export const unhandledInputs = (
|
|
209
|
+
entries: ReadonlyArray<AssistantEvent.AnyEntry>,
|
|
210
|
+
wakes: ReadonlySet<string>
|
|
211
|
+
): ReadonlyArray<{ readonly _tag: string }> => {
|
|
212
|
+
const seen = handledThrough(entries)
|
|
213
|
+
|
|
214
|
+
return entries.flatMap(({ event, seq }) =>
|
|
215
|
+
seq > seen && isInput(event, wakes) && !(AssistantEvent.isInput(event) && !AssistantEvent.isWaking(event))
|
|
216
|
+
? [event]
|
|
217
|
+
: []
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** @internal */
|
|
222
|
+
export const needsTurn = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, wakes: ReadonlySet<string>): boolean =>
|
|
223
|
+
unhandledInputs(entries, wakes).length > 0
|
|
224
|
+
|
|
225
|
+
// A failure the user was shown counts as a reply.
|
|
226
|
+
/** @internal */
|
|
227
|
+
export const owesReply = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, throughSeq: number): boolean => {
|
|
228
|
+
const latest = entries.findLast(
|
|
229
|
+
({ event, seq }) => seq <= throughSeq && AssistantEvent.isInput(event) && AssistantEvent.isUserInput(event)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
latest !== undefined &&
|
|
234
|
+
!entries.some(
|
|
235
|
+
({ event, seq }) =>
|
|
236
|
+
seq > latest.seq && (Predicate.isTagged(event, "AssistantMessage") || Predicate.isTagged(event, "TurnFailed"))
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const sentToWaitOn = Schema.decodeUnknownOption(Schema.Struct({ wait: Schema.Literal(false) }))
|
|
242
|
+
|
|
243
|
+
// Messages sent with `wait: false` are progress while the model kept working, like "On it".
|
|
244
|
+
const progressMessages = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): ReadonlySet<string> =>
|
|
245
|
+
new Set(
|
|
246
|
+
entries.flatMap(({ event }) =>
|
|
247
|
+
AssistantEvent.isLibraryEvent(event) && Predicate.isTagged(event, "ModelOutput")
|
|
248
|
+
? event.parts.flatMap((part) =>
|
|
249
|
+
part.type === "tool-call" && part.name === "send_user_message" && Option.isSome(sentToWaitOn(part.params))
|
|
250
|
+
? [Uuid.fromKey(`message:${part.id}`)]
|
|
251
|
+
: []
|
|
252
|
+
)
|
|
253
|
+
: []
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
// The model said it was on it and stopped without the result: only progress messages since the user's latest input.
|
|
258
|
+
// A specialist still working doesn't count, since its report is what comes next.
|
|
259
|
+
/** @internal */
|
|
260
|
+
export const owesDelivery = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, throughSeq: number): boolean => {
|
|
261
|
+
const latest = entries.findLast(
|
|
262
|
+
({ event, seq }) => seq <= throughSeq && AssistantEvent.isInput(event) && AssistantEvent.isUserInput(event)
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if (latest === undefined) return false
|
|
266
|
+
|
|
267
|
+
const after = entries.flatMap(({ seq, event }) =>
|
|
268
|
+
seq > latest.seq && AssistantEvent.isLibraryEvent(event) ? [event] : []
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
const messages = after.flatMap((event) => (Predicate.isTagged(event, "AssistantMessage") ? [event] : []))
|
|
272
|
+
const progress = progressMessages(entries)
|
|
273
|
+
const started = after.flatMap((event) => (Predicate.isTagged(event, "SpecialistStarted") ? [event.runId] : []))
|
|
274
|
+
|
|
275
|
+
const finished = new Set(
|
|
276
|
+
entries.flatMap(({ event }) =>
|
|
277
|
+
AssistantEvent.isLibraryEvent(event) && Predicate.isTagged(event, "SpecialistFinished") ? [event.runId] : []
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
messages.length > 0 &&
|
|
283
|
+
messages.every((message) => message.block === undefined && progress.has(message.id)) &&
|
|
284
|
+
!after.some((event) => Predicate.isTagged(event, "TurnFailed")) &&
|
|
285
|
+
started.every((runId) => finished.has(runId))
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** @internal */
|
|
290
|
+
export const unreadAfter = (
|
|
291
|
+
entries: ReadonlyArray<AssistantEvent.AnyEntry>,
|
|
292
|
+
throughSeq: number,
|
|
293
|
+
wakes: ReadonlySet<string>
|
|
294
|
+
): boolean => entries.some(({ event, seq }) => seq > throughSeq && isInput(event, wakes))
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import * as Predicate from "effect/Predicate"
|
|
2
|
+
import type * as Instructions from "../Instructions.ts"
|
|
3
|
+
|
|
4
|
+
/** @internal */
|
|
5
|
+
export const mapText = (
|
|
6
|
+
instructions: string | Instructions.Resolved,
|
|
7
|
+
f: (text: string) => string
|
|
8
|
+
): Instructions.Resolved =>
|
|
9
|
+
Predicate.isString(instructions) ? { text: f(instructions) } : { ...instructions, text: f(instructions.text) }
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import type * as AiError from "effect/ai/AiError"
|
|
2
|
+
import * as LanguageModel from "effect/ai/LanguageModel"
|
|
3
|
+
import * as Model from "effect/ai/Model"
|
|
4
|
+
import * as Prompt from "effect/ai/Prompt"
|
|
5
|
+
import type * as Response from "effect/ai/Response"
|
|
6
|
+
import type * as Tool from "effect/ai/Tool"
|
|
7
|
+
import type * as Toolkit from "effect/ai/Toolkit"
|
|
8
|
+
import * as Clock from "effect/Clock"
|
|
9
|
+
import * as Context from "effect/Context"
|
|
10
|
+
import * as Data from "effect/Data"
|
|
11
|
+
import * as Effect from "effect/Effect"
|
|
12
|
+
import * as Option from "effect/Option"
|
|
13
|
+
import * as Predicate from "effect/Predicate"
|
|
14
|
+
import * as Schema from "effect/Schema"
|
|
15
|
+
import * as Stream from "effect/Stream"
|
|
16
|
+
import * as AssistantEvent from "../AssistantEvent.ts"
|
|
17
|
+
import type * as Instructions from "../Instructions.ts"
|
|
18
|
+
import * as History from "./history.ts"
|
|
19
|
+
import * as Telemetry from "./telemetry.ts"
|
|
20
|
+
import * as Toolkits from "./toolkit.ts"
|
|
21
|
+
import { isTurnEnding } from "./tools.ts"
|
|
22
|
+
|
|
23
|
+
/** @internal */
|
|
24
|
+
export type Event = AssistantEvent.ModelOutput | AssistantEvent.ToolStarted | AssistantEvent.ToolResult
|
|
25
|
+
|
|
26
|
+
// The assistant's own tools with the turn's services, so an app's own loop in a turn can message the user. A
|
|
27
|
+
// specialist's run has none.
|
|
28
|
+
/** @internal */
|
|
29
|
+
export const TurnTools = Context.Reference<Option.Option<Toolkits.Tools>>("effect-assistant/TurnTools", {
|
|
30
|
+
defaultValue: Option.none
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/** @internal */
|
|
34
|
+
export interface ChatHistory {
|
|
35
|
+
readonly messages: ReadonlyArray<Prompt.Message>
|
|
36
|
+
readonly throughSeq: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const readHistory = (read: ReadonlyArray<Prompt.Message> | ChatHistory) =>
|
|
40
|
+
"messages" in read
|
|
41
|
+
? { messages: read.messages, throughSeq: read.throughSeq }
|
|
42
|
+
: { messages: read, throughSeq: undefined }
|
|
43
|
+
|
|
44
|
+
// `AssistantLoop` takes native toolkits; turns and specialist runs come here with their tools already merged.
|
|
45
|
+
/** @internal */
|
|
46
|
+
export interface StepOptions<R = never> {
|
|
47
|
+
readonly instructions: string | Instructions.Resolved
|
|
48
|
+
readonly toolkit?: Toolkits.Tools<R> | undefined
|
|
49
|
+
readonly history: Effect.Effect<ReadonlyArray<Prompt.Message> | ChatHistory>
|
|
50
|
+
readonly toolChoice?: LanguageModel.ToolChoice<string> | undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @internal */
|
|
54
|
+
export interface Options<R = never> extends StepOptions<R> {
|
|
55
|
+
readonly maxSteps?: number | undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Its member docs show through `AssistantLoop.StepResult`.
|
|
59
|
+
/** @internal */
|
|
60
|
+
export interface StepResult {
|
|
61
|
+
/** The model's output; unset when the step only ran calls a crash cut off. */
|
|
62
|
+
readonly output: AssistantEvent.ModelOutput | undefined
|
|
63
|
+
/**
|
|
64
|
+
* Whether the model is done: it made no tool calls, or ended its turn (`wait`, or a message
|
|
65
|
+
* with `wait: true`) and none of the step's calls failed.
|
|
66
|
+
*/
|
|
67
|
+
readonly done: boolean
|
|
68
|
+
/**
|
|
69
|
+
* The history's length with this step's output and results: a longer one got something new meanwhile.
|
|
70
|
+
*/
|
|
71
|
+
readonly historyLength: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The loop runs the calls itself, after storing each as started, so the model call runs none.
|
|
75
|
+
const requestToolkit = (definitions: ReadonlyArray<Tool.Any>): Toolkit.WithHandler<Record<string, Tool.Any>> => ({
|
|
76
|
+
tools: Object.fromEntries(definitions.map((tool) => [tool.name, tool])),
|
|
77
|
+
handle: () => Effect.succeed(Stream.empty)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// Tool call resolution stays on with a toolkit that runs nothing: with it off, a call whose params don't fit its
|
|
81
|
+
// tool's schema fails the whole model call, and here the model reads why instead.
|
|
82
|
+
const generate = (
|
|
83
|
+
prompt: Prompt.Prompt,
|
|
84
|
+
definitions: ReadonlyArray<Tool.Any>,
|
|
85
|
+
toolChoice: LanguageModel.ToolChoice<string> | undefined
|
|
86
|
+
): Effect.Effect<
|
|
87
|
+
LanguageModel.GenerateTextResponse<Record<string, Tool.Any>, "opaque">,
|
|
88
|
+
AiError.AiError,
|
|
89
|
+
LanguageModel.LanguageModel
|
|
90
|
+
> => LanguageModel.generateText({ prompt, toolkit: requestToolkit(definitions), toolChoice })
|
|
91
|
+
|
|
92
|
+
const decodePart = Schema.decodeUnknownOption(AssistantEvent.ModelPart)
|
|
93
|
+
|
|
94
|
+
const modelParts = (content: ReadonlyArray<Response.AllParts<Record<string, Tool.Any>>>) =>
|
|
95
|
+
content.flatMap((part) =>
|
|
96
|
+
Option.toArray(decodePart(part)).filter((decoded) => !(decoded.type === "tool-call" && decoded.providerExecuted))
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
const toolCalls = (parts: ReadonlyArray<AssistantEvent.ModelPart>): ReadonlyArray<AssistantEvent.ToolCallPart> =>
|
|
100
|
+
parts.flatMap((part) => (part.type === "tool-call" ? [part] : []))
|
|
101
|
+
|
|
102
|
+
const runCall = Effect.fnUntraced(function* <R>(tools: Toolkits.Tools<R>, call: AssistantEvent.ToolCallPart) {
|
|
103
|
+
const result = yield* Effect.gen(function* () {
|
|
104
|
+
const result = yield* tools.run(call)
|
|
105
|
+
|
|
106
|
+
yield* Telemetry.toolOutput(result.result)
|
|
107
|
+
|
|
108
|
+
return result
|
|
109
|
+
}).pipe(Telemetry.toolSpan(call))
|
|
110
|
+
|
|
111
|
+
return AssistantEvent.ToolResult.make({ callId: call.id, tool: call.name, ...result })
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// Every `ToolStarted` is stored before any call runs. `then` is told whether one of the calls failed, so the model
|
|
115
|
+
// has to read the results.
|
|
116
|
+
const runCalls = <R, S, E, R2>(
|
|
117
|
+
tools: Toolkits.Tools<R>,
|
|
118
|
+
calls: ReadonlyArray<AssistantEvent.ToolCallPart>,
|
|
119
|
+
then: (failed: boolean) => Stream.Stream<Step<never, S>, E, R2>
|
|
120
|
+
): Stream.Stream<Step<Event, S>, E, R | R2> =>
|
|
121
|
+
Stream.fromIterable(
|
|
122
|
+
calls.map((call) => value(AssistantEvent.ToolStarted.make({ callId: call.id, tool: call.name })))
|
|
123
|
+
).pipe(
|
|
124
|
+
Stream.concat(
|
|
125
|
+
Stream.unwrap(
|
|
126
|
+
Effect.gen(function* () {
|
|
127
|
+
const done = yield* Effect.forEach(calls, (call) => runCall(tools, call), { concurrency: "unbounded" })
|
|
128
|
+
|
|
129
|
+
return Stream.fromIterable(done.map(value)).pipe(Stream.concat(then(done.some(isFailure))))
|
|
130
|
+
})
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const isFailure = (result: AssistantEvent.ToolResult) => result.outcome === "failure"
|
|
136
|
+
|
|
137
|
+
/** @internal */
|
|
138
|
+
export type Step<A, S> = Data.TaggedEnum<{
|
|
139
|
+
Value: { readonly value: A }
|
|
140
|
+
Next: { readonly state: S }
|
|
141
|
+
Stop: Record<never, never>
|
|
142
|
+
}>
|
|
143
|
+
|
|
144
|
+
interface StepDefinition extends Data.TaggedEnum.WithGenerics<2> {
|
|
145
|
+
readonly taggedEnum: Step<this["A"], this["B"]>
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const Steps = Data.taggedEnum<StepDefinition>()
|
|
149
|
+
|
|
150
|
+
/** @internal */
|
|
151
|
+
export const value = <A>(value: A): Step<A, never> => Steps.Value({ value })
|
|
152
|
+
|
|
153
|
+
/** @internal */
|
|
154
|
+
export const next = <S>(state: S): Stream.Stream<Step<never, S>> => Stream.succeed(Steps.Next({ state }))
|
|
155
|
+
|
|
156
|
+
/** @internal */
|
|
157
|
+
// oxlint-disable-next-line no-unnecessary-type-parameters -- `stop<number>()` types a loop's state where inference can't.
|
|
158
|
+
export const stop = <S = never>(): Stream.Stream<Step<never, S>> => Stream.succeed(Steps.Stop())
|
|
159
|
+
|
|
160
|
+
const isValue = <A, S>(step: Step<A, S>): step is Data.TaggedEnum.Value<Step<A, S>, "Value"> =>
|
|
161
|
+
Predicate.isTagged(step, "Value")
|
|
162
|
+
|
|
163
|
+
// A body that ends without `next` or `stop` stops too.
|
|
164
|
+
/** @internal */
|
|
165
|
+
export const loop = <S, A, E, R>(
|
|
166
|
+
initial: S,
|
|
167
|
+
body: (state: S) => Stream.Stream<Step<A, S>, E, R>
|
|
168
|
+
): Stream.Stream<A, E, R> => {
|
|
169
|
+
const go = (state: S): Stream.Stream<A, E, R> =>
|
|
170
|
+
Stream.suspend(() => {
|
|
171
|
+
let after: Option.Option<S> = Option.none()
|
|
172
|
+
|
|
173
|
+
return body(state).pipe(
|
|
174
|
+
Stream.tap((step) =>
|
|
175
|
+
Effect.sync(() => (after = Predicate.isTagged(step, "Next") ? Option.some(step.state) : after))
|
|
176
|
+
),
|
|
177
|
+
Stream.filter(isValue),
|
|
178
|
+
Stream.map((step) => step.value),
|
|
179
|
+
Stream.concat(Stream.suspend(() => Option.match(after, { onNone: () => Stream.empty, onSome: go })))
|
|
180
|
+
)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
return go(initial)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** @internal */
|
|
187
|
+
export const step = <R, S, E2 = never, R2 = never>(
|
|
188
|
+
options: StepOptions<R>,
|
|
189
|
+
then: (result: StepResult) => Stream.Stream<Step<never, S>, E2, R2>
|
|
190
|
+
): Stream.Stream<Step<Event, S>, AiError.AiError | E2, LanguageModel.LanguageModel | R | R2> => {
|
|
191
|
+
const toolkit = options.toolkit ?? Toolkits.empty
|
|
192
|
+
const instructions = Predicate.isString(options.instructions) ? { text: options.instructions } : options.instructions
|
|
193
|
+
|
|
194
|
+
const after = (result: StepResult) => Stream.suspend(() => then(result))
|
|
195
|
+
const needingApproval = Toolkits.needingApproval(toolkit.definitions)
|
|
196
|
+
|
|
197
|
+
if (needingApproval.length > 0) return Stream.die(Toolkits.needsApprovalMessage(needingApproval))
|
|
198
|
+
|
|
199
|
+
return Stream.unwrap(
|
|
200
|
+
Effect.gen(function* () {
|
|
201
|
+
const { messages: history, throughSeq } = readHistory(yield* options.history)
|
|
202
|
+
// Calls a crash cut off, with no result: they run again, with the same ids, before the model goes on.
|
|
203
|
+
const cutOff = History.unansweredCalls(history)
|
|
204
|
+
|
|
205
|
+
if (cutOff.length > 0) {
|
|
206
|
+
return runCalls(toolkit, cutOff, () =>
|
|
207
|
+
after({ output: undefined, done: false, historyLength: history.length + cutOff.length })
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const prompt = Prompt.fromMessages([Prompt.systemMessage({ content: instructions.text }), ...history])
|
|
212
|
+
const name = yield* Effect.serviceOption(Model.ModelName)
|
|
213
|
+
const started = yield* Clock.currentTimeMillis
|
|
214
|
+
|
|
215
|
+
const response = yield* generate(prompt, toolkit.definitions, options.toolChoice).pipe(
|
|
216
|
+
Telemetry.chatSpan(prompt, instructions.attributes)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
const metadata = response.content.find((part) => part.type === "response-metadata")
|
|
220
|
+
|
|
221
|
+
const output = AssistantEvent.ModelOutput.make({
|
|
222
|
+
parts: modelParts(response.content),
|
|
223
|
+
seenThroughSeq: throughSeq,
|
|
224
|
+
model: metadata?.modelId ?? Option.getOrUndefined(name),
|
|
225
|
+
responseId: metadata?.id,
|
|
226
|
+
usage: response.usage,
|
|
227
|
+
durationMillis: (yield* Clock.currentTimeMillis) - started
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
const calls = toolCalls(output.parts)
|
|
231
|
+
const historyLength = history.length + Prompt.fromResponseParts(output.parts).content.length + calls.length
|
|
232
|
+
|
|
233
|
+
// A failed call doesn't end the turn: a message that failed never reached the user, so the model reads why.
|
|
234
|
+
return Stream.make(value(output)).pipe(
|
|
235
|
+
Stream.concat(
|
|
236
|
+
runCalls(toolkit, calls, (failed) =>
|
|
237
|
+
after({
|
|
238
|
+
output,
|
|
239
|
+
done: calls.length === 0 || (!failed && calls.some(isTurnEnding)),
|
|
240
|
+
historyLength
|
|
241
|
+
})
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
})
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** @internal */
|
|
250
|
+
export const run = <R = never>(
|
|
251
|
+
options: Options<R>
|
|
252
|
+
): Stream.Stream<Event, AiError.AiError, LanguageModel.LanguageModel | R> => {
|
|
253
|
+
const maxSteps = options.maxSteps ?? 16
|
|
254
|
+
|
|
255
|
+
// Done: stop, unless something arrived while the model was working; then it reads that first.
|
|
256
|
+
const afterDone = (steps: number, historyLength: number) =>
|
|
257
|
+
Stream.unwrap(
|
|
258
|
+
Effect.gen(function* () {
|
|
259
|
+
const read = yield* options.history
|
|
260
|
+
|
|
261
|
+
return readHistory(read).messages.length > historyLength ? next(steps) : stop<number>()
|
|
262
|
+
})
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
return loop(0, (steps: number) =>
|
|
266
|
+
step(options, (result) => {
|
|
267
|
+
const taken = result.output === undefined ? steps : steps + 1
|
|
268
|
+
|
|
269
|
+
if (taken >= maxSteps) return stop<number>()
|
|
270
|
+
|
|
271
|
+
return result.done ? afterDone(taken, result.historyLength) : next(taken)
|
|
272
|
+
})
|
|
273
|
+
)
|
|
274
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { flow } from "effect/Function"
|
|
2
|
+
import * as Option from "effect/Option"
|
|
3
|
+
import * as Schema from "effect/Schema"
|
|
4
|
+
|
|
5
|
+
const JsonText = Schema.fromJsonString(Schema.Unknown)
|
|
6
|
+
|
|
7
|
+
// A value with no JSON form (`undefined`, a cycle) is empty text rather than a defect.
|
|
8
|
+
/** @internal */
|
|
9
|
+
export const toJsonText = flow(
|
|
10
|
+
Schema.encodeUnknownOption(JsonText),
|
|
11
|
+
Option.getOrElse(() => "")
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
/** @internal */
|
|
15
|
+
export const isJsonText = (text: string): boolean => Option.isSome(Schema.decodeOption(JsonText)(text))
|
|
16
|
+
|
|
17
|
+
const decodeJsonText = Schema.decodeOption(Schema.fromJsonString(Schema.Json))
|
|
18
|
+
|
|
19
|
+
// As `JSON.stringify` writes it (a `Date` as its ISO string, a class as its fields), or `null` when it has none.
|
|
20
|
+
/** @internal */
|
|
21
|
+
export const toJson = flow(
|
|
22
|
+
Schema.encodeUnknownOption(JsonText),
|
|
23
|
+
Option.flatMap(decodeJsonText),
|
|
24
|
+
Option.getOrElse((): Schema.Json => null)
|
|
25
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as Tool from "effect/ai/Tool"
|
|
2
|
+
import * as Toolkit from "effect/ai/Toolkit"
|
|
3
|
+
import type * as Context from "effect/Context"
|
|
4
|
+
import * as Effect from "effect/Effect"
|
|
5
|
+
import * as Schema from "effect/Schema"
|
|
6
|
+
import * as Instructions from "../Instructions.ts"
|
|
7
|
+
import type { Skill } from "../Skill.ts"
|
|
8
|
+
import * as ToolFailure from "../ToolFailure.ts"
|
|
9
|
+
import * as Toolkits from "./toolkit.ts"
|
|
10
|
+
|
|
11
|
+
/** @internal */
|
|
12
|
+
export const toTools = <R>(skills: ReadonlyArray<Skill<R>>): Toolkits.Tools<R> => {
|
|
13
|
+
if (skills.length === 0) return Toolkits.empty
|
|
14
|
+
|
|
15
|
+
const byName = new Map(skills.map((skill) => [skill.name, skill]))
|
|
16
|
+
|
|
17
|
+
const LoadSkill = Tool.make("load_skill", {
|
|
18
|
+
description:
|
|
19
|
+
"Load a skill's instructions by name, in the same step as your first lookups (not in a step of its own). Follow them for the rest of the task.",
|
|
20
|
+
parameters: Schema.Struct({ name: Schema.Literals(skills.map((skill) => skill.name)) }),
|
|
21
|
+
success: Schema.String,
|
|
22
|
+
failure: ToolFailure.ToolFailure
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// A skill's instructions resolve with the services of the loop that loads it.
|
|
26
|
+
return Toolkits.withContext(Toolkit.make(LoadSkill), (context: Context.Context<R>) => ({
|
|
27
|
+
load_skill: Effect.fnUntraced(function* ({ name }) {
|
|
28
|
+
const skill = byName.get(name)
|
|
29
|
+
|
|
30
|
+
if (skill === undefined) return yield* ToolFailure.fail(`No skill named ${name}.`)
|
|
31
|
+
|
|
32
|
+
const resolved = yield* Instructions.resolve(skill.instructions).pipe(Effect.provideContext(context))
|
|
33
|
+
|
|
34
|
+
return resolved.text
|
|
35
|
+
})
|
|
36
|
+
}))
|
|
37
|
+
}
|