effect-assistant 0.4.0 → 0.6.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 +10 -10
- package/dist/Assistant.d.ts.map +1 -1
- package/dist/Assistant.js +1 -1
- package/dist/Assistant.js.map +1 -1
- package/dist/AssistantActivity.d.ts +2 -2
- package/dist/AssistantActivity.d.ts.map +1 -1
- package/dist/AssistantActivity.js +5 -5
- package/dist/AssistantActivity.js.map +1 -1
- package/dist/AssistantEngine.d.ts +3 -3
- package/dist/AssistantEngine.js +2 -2
- package/dist/AssistantEngine.js.map +1 -1
- package/dist/AssistantEvent.d.ts +19 -8
- package/dist/AssistantEvent.d.ts.map +1 -1
- package/dist/AssistantEvent.js +20 -9
- package/dist/AssistantEvent.js.map +1 -1
- package/dist/AssistantLoop.d.ts +2 -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/ChatLog.d.ts +9 -9
- package/dist/ChatLog.d.ts.map +1 -1
- package/dist/ChatLog.js +21 -21
- package/dist/ChatLog.js.map +1 -1
- package/dist/Compaction.d.ts +10 -10
- package/dist/Compaction.d.ts.map +1 -1
- package/dist/Compaction.js +19 -19
- package/dist/Compaction.js.map +1 -1
- package/dist/Memory.d.ts +1 -1
- package/dist/Memory.js +1 -1
- package/dist/Memory.js.map +1 -1
- package/dist/Skill.d.ts +1 -1
- package/dist/Skill.js +1 -1
- package/dist/Skill.js.map +1 -1
- package/dist/Specialist.d.ts +1 -1
- package/dist/Specialist.js +1 -1
- package/dist/Specialist.js.map +1 -1
- package/dist/Timeline.d.ts +13 -9
- package/dist/Timeline.d.ts.map +1 -1
- package/dist/Timeline.js +35 -23
- package/dist/Timeline.js.map +1 -1
- package/dist/ToolApproval.d.ts +1 -1
- package/dist/ToolApproval.js +1 -1
- package/dist/ToolApproval.js.map +1 -1
- package/dist/ToolDiscovery.d.ts +1 -1
- package/dist/ToolDiscovery.js +1 -1
- 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 +2 -1
- package/dist/index.js +4 -3
- package/dist/internal/chat.js +4 -4
- package/dist/internal/chat.js.map +1 -1
- package/dist/internal/events.js +1 -1
- package/dist/internal/events.js.map +1 -1
- package/dist/internal/history.js +23 -23
- package/dist/internal/history.js.map +1 -1
- package/dist/internal/loop.d.ts +1 -1
- package/dist/internal/loop.d.ts.map +1 -1
- package/dist/internal/loop.js +12 -6
- package/dist/internal/loop.js.map +1 -1
- package/dist/internal/toolkit.d.ts +7 -1
- package/dist/internal/toolkit.d.ts.map +1 -1
- package/dist/internal/toolkit.js +47 -4
- 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 -21
- package/dist/internal/turns.js.map +1 -1
- package/package.json +1 -1
- package/src/Assistant.ts +11 -11
- package/src/AssistantActivity.ts +9 -7
- package/src/AssistantEngine.ts +3 -3
- package/src/AssistantEvent.ts +21 -10
- package/src/AssistantLoop.ts +3 -3
- package/src/Block.ts +82 -1
- package/src/BlockKit.ts +37 -4
- package/src/ChatLog.ts +31 -26
- package/src/Compaction.ts +21 -21
- package/src/Memory.ts +1 -1
- package/src/Skill.ts +1 -1
- package/src/Specialist.ts +1 -1
- package/src/Timeline.ts +38 -25
- package/src/ToolApproval.ts +1 -1
- package/src/ToolDiscovery.ts +1 -1
- package/src/ToolResult.ts +80 -0
- package/src/index.ts +5 -0
- package/src/internal/chat.ts +4 -4
- package/src/internal/events.ts +1 -1
- package/src/internal/history.ts +32 -28
- package/src/internal/loop.ts +24 -7
- package/src/internal/toolkit.ts +55 -5
- package/src/internal/tools.ts +81 -5
- package/src/internal/turns.ts +35 -29
package/src/internal/history.ts
CHANGED
|
@@ -140,7 +140,7 @@ const awaitingMessage = (request: AssistantEvent.ApprovalRequested): Prompt.Tool
|
|
|
140
140
|
})
|
|
141
141
|
|
|
142
142
|
const isEntry = (item: AssistantEvent.AnyEntry | Prompt.Message): item is AssistantEvent.AnyEntry =>
|
|
143
|
-
Predicate.hasProperty(item, "
|
|
143
|
+
Predicate.hasProperty(item, "sequence")
|
|
144
144
|
|
|
145
145
|
// In the order the model saw the entries, not the order they were stored. A message a history view put in stands
|
|
146
146
|
// where it is, right after the entry before it, and waits like an input for a call's result or for the model's next
|
|
@@ -166,15 +166,15 @@ export const fromEntries = (
|
|
|
166
166
|
const next = entries.slice(from + 1).find(({ event }) => Predicate.isTagged(event, "ModelOutput"))?.event
|
|
167
167
|
|
|
168
168
|
return next !== undefined && AssistantEvent.isLibraryEvent(next) && Predicate.isTagged(next, "ModelOutput")
|
|
169
|
-
? next.
|
|
169
|
+
? next.seenThroughSequence
|
|
170
170
|
: undefined
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
// `index` is the last entry at or before the item, and `
|
|
174
|
-
const input = (index: number,
|
|
173
|
+
// `index` is the last entry at or before the item, and `sequence` where the item stands.
|
|
174
|
+
const input = (index: number, sequence: number, item: Prompt.Message) => {
|
|
175
175
|
const seen = nextSeen(index)
|
|
176
176
|
|
|
177
|
-
if (open.size > 0 || (seen !== undefined && seen <
|
|
177
|
+
if (open.size > 0 || (seen !== undefined && seen < sequence)) held.push(item)
|
|
178
178
|
else history.push(item)
|
|
179
179
|
}
|
|
180
180
|
|
|
@@ -182,14 +182,14 @@ export const fromEntries = (
|
|
|
182
182
|
|
|
183
183
|
for (const item of items) {
|
|
184
184
|
if (!isEntry(item)) {
|
|
185
|
-
input(index, (entries[index]?.
|
|
185
|
+
input(index, (entries[index]?.sequence ?? 0) + 0.5, item)
|
|
186
186
|
|
|
187
187
|
continue
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
index += 1
|
|
191
191
|
|
|
192
|
-
const { event,
|
|
192
|
+
const { event, sequence } = item
|
|
193
193
|
const at = index
|
|
194
194
|
|
|
195
195
|
if (!AssistantEvent.isLibraryEvent(event)) continue
|
|
@@ -197,8 +197,8 @@ export const fromEntries = (
|
|
|
197
197
|
Match.value(event).pipe(
|
|
198
198
|
Match.tagsExhaustive({
|
|
199
199
|
UserMessage: (message) =>
|
|
200
|
-
input(at,
|
|
201
|
-
BlockAnswer: (answer) => input(at,
|
|
200
|
+
input(at, sequence, userText(AssistantEvent.textWithAttachments(message.text, message.attachments))),
|
|
201
|
+
BlockAnswer: (answer) => input(at, sequence, userText(answerText(answer))),
|
|
202
202
|
ModelOutput: (output) => {
|
|
203
203
|
history.push(...Prompt.fromResponseParts(output.parts).content)
|
|
204
204
|
|
|
@@ -212,11 +212,11 @@ export const fromEntries = (
|
|
|
212
212
|
flushIfClosed()
|
|
213
213
|
},
|
|
214
214
|
ToolResult: (result) => {
|
|
215
|
-
if (requests.has(result.callId)) return input(at,
|
|
215
|
+
if (requests.has(result.callId)) return input(at, sequence, userText(approvedText(result)))
|
|
216
216
|
|
|
217
217
|
// A view that starts after the call (since a summary, say) keeps its result as text: a tool result needs its
|
|
218
218
|
// call right before it.
|
|
219
|
-
if (!called.has(result.callId)) return input(at,
|
|
219
|
+
if (!called.has(result.callId)) return input(at, sequence, userText(resultText(result)))
|
|
220
220
|
|
|
221
221
|
history.push(toolMessage(result))
|
|
222
222
|
open.delete(result.callId)
|
|
@@ -227,7 +227,7 @@ export const fromEntries = (
|
|
|
227
227
|
TurnStarted: () => undefined,
|
|
228
228
|
TurnCompleted: () => undefined,
|
|
229
229
|
TurnFailed: () => undefined,
|
|
230
|
-
SpecialistFinished: (finished) => input(at,
|
|
230
|
+
SpecialistFinished: (finished) => input(at, sequence, userText(finishedText(entries, at, finished))),
|
|
231
231
|
// Every run starts in a call of the model's own (a spawn or a follow-up), whose result says so.
|
|
232
232
|
SpecialistStarted: () => undefined,
|
|
233
233
|
BlocksClosed: () => undefined,
|
|
@@ -235,7 +235,7 @@ export const fromEntries = (
|
|
|
235
235
|
ApprovalRequested: (request) => {
|
|
236
236
|
requests.set(request.callId, request)
|
|
237
237
|
|
|
238
|
-
if (!called.has(request.callId)) return input(at,
|
|
238
|
+
if (!called.has(request.callId)) return input(at, sequence, userText(awaitingText(request)))
|
|
239
239
|
|
|
240
240
|
history.push(awaitingMessage(request))
|
|
241
241
|
open.delete(request.callId)
|
|
@@ -244,10 +244,10 @@ export const fromEntries = (
|
|
|
244
244
|
// An approved call's result says so.
|
|
245
245
|
ApprovalDecision: (decision) =>
|
|
246
246
|
decision.decision === "rejected"
|
|
247
|
-
? input(at,
|
|
247
|
+
? input(at, sequence, userText(rejectedText(decision, toolOf(decision.callId))))
|
|
248
248
|
: undefined,
|
|
249
249
|
ApprovalsExpired: (expired) =>
|
|
250
|
-
expired.callIds.forEach((callId) => input(at,
|
|
250
|
+
expired.callIds.forEach((callId) => input(at, sequence, userText(expiredText(callId, toolOf(callId))))),
|
|
251
251
|
ChatDeleted: () => undefined
|
|
252
252
|
})
|
|
253
253
|
)
|
|
@@ -264,7 +264,7 @@ export const isInput = (event: { readonly _tag: string }): boolean =>
|
|
|
264
264
|
export const handledThrough = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): number => {
|
|
265
265
|
const last = entries.findLast(({ event }) => AssistantEvent.isTurnEnd(event))?.event
|
|
266
266
|
|
|
267
|
-
return last !== undefined && AssistantEvent.isTurnEnd(last) ? last.
|
|
267
|
+
return last !== undefined && AssistantEvent.isTurnEnd(last) ? last.throughSequence : 0
|
|
268
268
|
}
|
|
269
269
|
|
|
270
270
|
// A run the assistant stopped isn't an input here: it wakes nothing.
|
|
@@ -274,8 +274,10 @@ export const unhandledInputs = (
|
|
|
274
274
|
): ReadonlyArray<{ readonly _tag: string }> => {
|
|
275
275
|
const seen = handledThrough(entries)
|
|
276
276
|
|
|
277
|
-
return entries.flatMap(({ event,
|
|
278
|
-
|
|
277
|
+
return entries.flatMap(({ event, sequence }) =>
|
|
278
|
+
sequence > seen && isInput(event) && !(AssistantEvent.isInput(event) && !AssistantEvent.isWaking(event))
|
|
279
|
+
? [event]
|
|
280
|
+
: []
|
|
279
281
|
)
|
|
280
282
|
}
|
|
281
283
|
|
|
@@ -285,16 +287,17 @@ export const needsTurn = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): bool
|
|
|
285
287
|
|
|
286
288
|
// A failure the user was shown counts as a reply, and so does a call they're asked to approve.
|
|
287
289
|
/** @internal */
|
|
288
|
-
export const owesReply = (entries: ReadonlyArray<AssistantEvent.AnyEntry>,
|
|
290
|
+
export const owesReply = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, throughSequence: number): boolean => {
|
|
289
291
|
const latest = entries.findLast(
|
|
290
|
-
({ event,
|
|
292
|
+
({ event, sequence }) =>
|
|
293
|
+
sequence <= throughSequence && AssistantEvent.isInput(event) && AssistantEvent.isUserInput(event)
|
|
291
294
|
)
|
|
292
295
|
|
|
293
296
|
return (
|
|
294
297
|
latest !== undefined &&
|
|
295
298
|
!entries.some(
|
|
296
|
-
({ event,
|
|
297
|
-
|
|
299
|
+
({ event, sequence }) =>
|
|
300
|
+
sequence > latest.sequence &&
|
|
298
301
|
(Predicate.isTagged(event, "AssistantMessage") ||
|
|
299
302
|
Predicate.isTagged(event, "TurnFailed") ||
|
|
300
303
|
Predicate.isTagged(event, "ApprovalRequested"))
|
|
@@ -321,15 +324,16 @@ const progressMessages = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): Read
|
|
|
321
324
|
// The model said it was on it and stopped without the result: only progress messages since the user's latest input.
|
|
322
325
|
// A specialist still working doesn't count, since its report is what comes next.
|
|
323
326
|
/** @internal */
|
|
324
|
-
export const owesDelivery = (entries: ReadonlyArray<AssistantEvent.AnyEntry>,
|
|
327
|
+
export const owesDelivery = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, throughSequence: number): boolean => {
|
|
325
328
|
const latest = entries.findLast(
|
|
326
|
-
({ event,
|
|
329
|
+
({ event, sequence }) =>
|
|
330
|
+
sequence <= throughSequence && AssistantEvent.isInput(event) && AssistantEvent.isUserInput(event)
|
|
327
331
|
)
|
|
328
332
|
|
|
329
333
|
if (latest === undefined) return false
|
|
330
334
|
|
|
331
|
-
const after = entries.flatMap(({
|
|
332
|
-
|
|
335
|
+
const after = entries.flatMap(({ sequence, event }) =>
|
|
336
|
+
sequence > latest.sequence && AssistantEvent.isLibraryEvent(event) ? [event] : []
|
|
333
337
|
)
|
|
334
338
|
|
|
335
339
|
const messages = after.flatMap((event) => (Predicate.isTagged(event, "AssistantMessage") ? [event] : []))
|
|
@@ -352,5 +356,5 @@ export const owesDelivery = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, th
|
|
|
352
356
|
}
|
|
353
357
|
|
|
354
358
|
/** @internal */
|
|
355
|
-
export const unreadAfter = (entries: ReadonlyArray<AssistantEvent.AnyEntry>,
|
|
356
|
-
entries.some(({ event,
|
|
359
|
+
export const unreadAfter = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, throughSequence: number): boolean =>
|
|
360
|
+
entries.some(({ event, sequence }) => sequence > throughSequence && isInput(event))
|
package/src/internal/loop.ts
CHANGED
|
@@ -18,7 +18,7 @@ import type * as Instructions from "../Instructions.ts"
|
|
|
18
18
|
import * as History from "./history.ts"
|
|
19
19
|
import * as Telemetry from "./telemetry.ts"
|
|
20
20
|
import * as Toolkits from "./toolkit.ts"
|
|
21
|
-
import { isTurnEnding } from "./tools.ts"
|
|
21
|
+
import { isTurnEnding, isUserMessage } from "./tools.ts"
|
|
22
22
|
|
|
23
23
|
/** @internal */
|
|
24
24
|
export type Event =
|
|
@@ -44,13 +44,13 @@ export const TurnTools = Context.Reference<Option.Option<Toolkits.Tools>>("effec
|
|
|
44
44
|
/** @internal */
|
|
45
45
|
export interface ChatHistory {
|
|
46
46
|
readonly messages: ReadonlyArray<Prompt.Message>
|
|
47
|
-
readonly
|
|
47
|
+
readonly throughSequence: number
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
const readHistory = (read: ReadonlyArray<Prompt.Message> | ChatHistory) =>
|
|
51
51
|
"messages" in read
|
|
52
|
-
? { messages: read.messages,
|
|
53
|
-
: { messages: read,
|
|
52
|
+
? { messages: read.messages, throughSequence: read.throughSequence }
|
|
53
|
+
: { messages: read, throughSequence: undefined }
|
|
54
54
|
|
|
55
55
|
// `AssistantLoop` takes native toolkits; turns and specialist runs come here with their tools already merged.
|
|
56
56
|
/** @internal */
|
|
@@ -158,6 +158,17 @@ interface Ran {
|
|
|
158
158
|
readonly waiting: boolean
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
// The approval shows the call, not what it does: the user learns that from the assistant's message beside it.
|
|
162
|
+
const unexplained = (call: AssistantEvent.ToolCallPart) =>
|
|
163
|
+
AssistantEvent.ToolResult.make({
|
|
164
|
+
callId: call.id,
|
|
165
|
+
tool: call.name,
|
|
166
|
+
...Toolkits.failed(
|
|
167
|
+
`Not sent for approval: the user would see buttons without knowing what they approve. In one step, send them a message with send_user_message saying what ${call.name} will do (what, to whom, how many), and call ${call.name} again.`,
|
|
168
|
+
"ApprovalNeedsMessage"
|
|
169
|
+
)
|
|
170
|
+
})
|
|
171
|
+
|
|
161
172
|
// Every `ToolStarted` is stored before any call runs; a call that waits for approval doesn't start.
|
|
162
173
|
const runCalls = <R, S, E, R2>(
|
|
163
174
|
tools: Toolkits.Tools<R>,
|
|
@@ -179,10 +190,16 @@ const runCalls = <R, S, E, R2>(
|
|
|
179
190
|
Stream.concat(
|
|
180
191
|
Stream.unwrap(
|
|
181
192
|
Effect.gen(function* () {
|
|
193
|
+
const explained = pending.some(({ call }) => isUserMessage(call))
|
|
194
|
+
|
|
182
195
|
const done = yield* Effect.forEach(
|
|
183
196
|
pending,
|
|
184
197
|
({ call }, i): Effect.Effect<Event, never, R> =>
|
|
185
|
-
waits[i]
|
|
198
|
+
waits[i] !== true
|
|
199
|
+
? runCall(tools, call)
|
|
200
|
+
: explained
|
|
201
|
+
? awaitApproval(call)
|
|
202
|
+
: Effect.succeed(unexplained(call)),
|
|
186
203
|
{ concurrency: "unbounded" }
|
|
187
204
|
)
|
|
188
205
|
|
|
@@ -264,7 +281,7 @@ export const step = <R, S, E2 = never, R2 = never>(
|
|
|
264
281
|
|
|
265
282
|
return Stream.unwrap(
|
|
266
283
|
Effect.gen(function* () {
|
|
267
|
-
const { messages: history,
|
|
284
|
+
const { messages: history, throughSequence } = readHistory(yield* options.history)
|
|
268
285
|
// Calls a crash cut off, with no result: they run again, with the same ids, before the model goes on.
|
|
269
286
|
const cutOff = History.unansweredCalls(history)
|
|
270
287
|
const approvals = yield* TurnApprovals
|
|
@@ -301,7 +318,7 @@ export const step = <R, S, E2 = never, R2 = never>(
|
|
|
301
318
|
|
|
302
319
|
const output = AssistantEvent.ModelOutput.make({
|
|
303
320
|
parts: modelParts(response.content),
|
|
304
|
-
|
|
321
|
+
seenThroughSequence: throughSequence,
|
|
305
322
|
model: metadata?.modelId ?? Option.getOrUndefined(name),
|
|
306
323
|
responseId: metadata?.id,
|
|
307
324
|
usage: response.usage,
|
package/src/internal/toolkit.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as AiError from "effect/ai/AiError"
|
|
|
2
2
|
import type * as Prompt from "effect/ai/Prompt"
|
|
3
3
|
import type * as Tool from "effect/ai/Tool"
|
|
4
4
|
import type * as Toolkit from "effect/ai/Toolkit"
|
|
5
|
-
import
|
|
5
|
+
import * as Context from "effect/Context"
|
|
6
6
|
import * as Effect from "effect/Effect"
|
|
7
7
|
import * as Option from "effect/Option"
|
|
8
8
|
import * as Predicate from "effect/Predicate"
|
|
@@ -11,13 +11,17 @@ import * as Stream from "effect/Stream"
|
|
|
11
11
|
import type * as AssistantEvent from "../AssistantEvent.ts"
|
|
12
12
|
import { AssistantToolCall } from "../AssistantToolCall.ts"
|
|
13
13
|
import * as ToolFailure from "../ToolFailure.ts"
|
|
14
|
+
import { ModelView } from "../ToolResult.ts"
|
|
14
15
|
import { toJson } from "./schema.ts"
|
|
16
|
+
import * as Uuid from "./uuid.ts"
|
|
15
17
|
|
|
16
18
|
/** @internal */
|
|
17
19
|
export interface CallResult {
|
|
18
20
|
readonly outcome: AssistantEvent.ToolOutcome
|
|
19
21
|
readonly result: Schema.Json
|
|
20
22
|
readonly reason?: string | undefined
|
|
23
|
+
readonly full?: Schema.Json | undefined
|
|
24
|
+
readonly resultId?: string | undefined
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
// Apps give the loop native toolkits; these are what the loop makes of them, merged with its own.
|
|
@@ -119,12 +123,22 @@ export const runNative = Effect.fnUntraced(function* <T extends Record<string, T
|
|
|
119
123
|
const results = yield* toolkit.handle<keyof T>(name, params, call.id)
|
|
120
124
|
const last = yield* Stream.runLast(Stream.filter(results, (result) => !result.preliminary))
|
|
121
125
|
|
|
126
|
+
const view = Option.flatMap(Option.fromUndefinedOr(toolNamed(toolkit, call.name)), (tool) =>
|
|
127
|
+
Context.getOption(tool.annotations, ModelView)
|
|
128
|
+
)
|
|
129
|
+
|
|
122
130
|
return Option.match(last, {
|
|
123
131
|
onNone: () => failed(`${call.name} returned no result.`, "NoResult"),
|
|
124
|
-
onSome: (result): CallResult =>
|
|
125
|
-
result.isFailure
|
|
126
|
-
|
|
127
|
-
|
|
132
|
+
onSome: (result): CallResult => {
|
|
133
|
+
if (result.isFailure) return failure(toJson(result.encodedResult))
|
|
134
|
+
|
|
135
|
+
const full = toJson(result.encodedResult)
|
|
136
|
+
|
|
137
|
+
return Option.match(view, {
|
|
138
|
+
onNone: () => ({ outcome: "success", result: full }),
|
|
139
|
+
onSome: (read) => ({ outcome: "success", result: toJson(read(result.result)), full })
|
|
140
|
+
})
|
|
141
|
+
}
|
|
128
142
|
})
|
|
129
143
|
}).pipe(
|
|
130
144
|
Effect.catch((error) =>
|
|
@@ -180,6 +194,42 @@ export const merge = <A, B>(self: Tools<A>, that: Tools<B>): Tools<A | B> => {
|
|
|
180
194
|
}
|
|
181
195
|
}
|
|
182
196
|
|
|
197
|
+
// Short, for the model to copy, and from the call: a call run again after a crash keeps its id.
|
|
198
|
+
/** @internal */
|
|
199
|
+
export const resultIdOf = (callId: string): string => `r-${Uuid.fromKey(`result:${callId}`).slice(0, 8)}`
|
|
200
|
+
|
|
201
|
+
const decodeJsonObject = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Json))
|
|
202
|
+
|
|
203
|
+
const withResultId = (result: CallResult, resultId: string): CallResult => {
|
|
204
|
+
if (result.outcome === "failure") return result
|
|
205
|
+
|
|
206
|
+
const read = result.result
|
|
207
|
+
const fields = Array.isArray(read) ? Option.none() : decodeJsonObject(read)
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
...result,
|
|
211
|
+
result: Option.match(fields, {
|
|
212
|
+
onNone: () => ({ resultId, result: read }),
|
|
213
|
+
onSome: (object) => ({ resultId, ...object })
|
|
214
|
+
}),
|
|
215
|
+
full: result.full ?? read,
|
|
216
|
+
resultId
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// The results of tools the assistant's blocks show get an id the model passes to show one.
|
|
221
|
+
/** @internal */
|
|
222
|
+
export const showing = <R>(tools: Tools<R>, names: ReadonlySet<string>): Tools<R> =>
|
|
223
|
+
names.size === 0
|
|
224
|
+
? tools
|
|
225
|
+
: {
|
|
226
|
+
...tools,
|
|
227
|
+
run: (call) =>
|
|
228
|
+
names.has(call.name)
|
|
229
|
+
? Effect.map(tools.run(call), (result) => withResultId(result, resultIdOf(call.id)))
|
|
230
|
+
: tools.run(call)
|
|
231
|
+
}
|
|
232
|
+
|
|
183
233
|
// A turn's own services, for a loop the app writes.
|
|
184
234
|
/** @internal */
|
|
185
235
|
export const provide = <R>(tools: Tools<R>, context: Context.Context<R>): Tools => ({
|
package/src/internal/tools.ts
CHANGED
|
@@ -6,6 +6,7 @@ import * as Predicate from "effect/Predicate"
|
|
|
6
6
|
import * as Schema from "effect/Schema"
|
|
7
7
|
import * as AssistantEvent from "../AssistantEvent.ts"
|
|
8
8
|
import { AssistantToolCall } from "../AssistantToolCall.ts"
|
|
9
|
+
import * as Block from "../Block.ts"
|
|
9
10
|
import type * as BlockKit from "../BlockKit.ts"
|
|
10
11
|
import { CurrentChat } from "../CurrentChat.ts"
|
|
11
12
|
import * as ToolFailure from "../ToolFailure.ts"
|
|
@@ -34,6 +35,13 @@ const WaitFlag = Schema.Boolean.annotate({
|
|
|
34
35
|
|
|
35
36
|
const endsWithMessage = Schema.decodeUnknownOption(Schema.Struct({ wait: Schema.Literal(true) }))
|
|
36
37
|
|
|
38
|
+
const messageText = Schema.decodeUnknownOption(Schema.Struct({ text: Schema.String }))
|
|
39
|
+
|
|
40
|
+
// A message the user reads: the call's text isn't empty.
|
|
41
|
+
/** @internal */
|
|
42
|
+
export const isUserMessage = (call: AssistantEvent.ToolCallPart): boolean =>
|
|
43
|
+
call.name === sendUserMessageName && Option.exists(messageText(call.params), ({ text }) => text.trim() !== "")
|
|
44
|
+
|
|
37
45
|
/** @internal */
|
|
38
46
|
export const isTurnEnding = (call: AssistantEvent.ToolCallPart): boolean =>
|
|
39
47
|
call.name === waitToolName || (call.name === sendUserMessageName && Option.isSome(endsWithMessage(call.params)))
|
|
@@ -76,9 +84,56 @@ const plainTools = Effect.gen(function* () {
|
|
|
76
84
|
return yield* Effect.provideContext(PlainTools, yield* plainHandlers)
|
|
77
85
|
})
|
|
78
86
|
|
|
87
|
+
/** @internal */
|
|
88
|
+
export const toolResultKind = "tool_result"
|
|
89
|
+
|
|
90
|
+
const ToolResultRef = Schema.Struct({
|
|
91
|
+
resultId: Schema.String.annotate({ description: "The resultId of the tool result to show." })
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const decodeRef = Schema.decodeUnknownOption(ToolResultRef)
|
|
95
|
+
|
|
96
|
+
// The latest successful result with that id, of a tool the kit shows.
|
|
97
|
+
const shownResult = Effect.fnUntraced(function* (resultId: string, shown: ReadonlySet<string>) {
|
|
98
|
+
const entries = yield* (yield* CurrentChat).entries
|
|
99
|
+
|
|
100
|
+
const result = entries
|
|
101
|
+
.flatMap(({ event }) =>
|
|
102
|
+
AssistantEvent.isLibraryEvent(event) && Predicate.isTagged(event, "ToolResult") && event.resultId === resultId
|
|
103
|
+
? [event]
|
|
104
|
+
: []
|
|
105
|
+
)
|
|
106
|
+
.at(-1)
|
|
107
|
+
|
|
108
|
+
if (result === undefined || result.outcome === "failure") {
|
|
109
|
+
return yield* ToolFailure.fail(
|
|
110
|
+
`No tool result ${resultId} to show: pass the resultId of a result you got in this chat.`,
|
|
111
|
+
{ reason: "ResultNotFound" }
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!shown.has(result.tool)) {
|
|
116
|
+
return yield* ToolFailure.fail(`${result.tool} results can't be shown.`, { reason: "ResultNotShown" })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return result
|
|
120
|
+
})
|
|
121
|
+
|
|
79
122
|
const blockTools = Effect.fnUntraced(function* (kit: BlockKit.BlockKit) {
|
|
80
|
-
const
|
|
81
|
-
const
|
|
123
|
+
const asked = kit.blocks.filter((block) => !Block.isToolResult(block))
|
|
124
|
+
const shown = new Set(kit.blocks.flatMap((block) => (Block.isToolResult(block) ? [block.tool.name] : [])))
|
|
125
|
+
|
|
126
|
+
const catalog = [
|
|
127
|
+
...asked.map((block) => `- ${block.kind}: ${block.description}`),
|
|
128
|
+
...(shown.size === 0
|
|
129
|
+
? []
|
|
130
|
+
: [
|
|
131
|
+
`- ${toolResultKind}: a tool's result, shown the way the app draws it (found items as cards, a download as a button). Results you can show have a resultId: pass it as the props. From: ${[...shown].join(", ")}.`
|
|
132
|
+
])
|
|
133
|
+
].join("\n")
|
|
134
|
+
|
|
135
|
+
const kinds = [...asked.map((block) => block.kind), ...(shown.size === 0 ? [] : [toolResultKind])]
|
|
136
|
+
const props = [...asked.map((block) => block.props), ...(shown.size === 0 ? [] : [ToolResultRef])]
|
|
82
137
|
|
|
83
138
|
const BlockMessage = Tool.make(sendUserMessageName, {
|
|
84
139
|
description: `${description}
|
|
@@ -92,7 +147,7 @@ ${catalog}`,
|
|
|
92
147
|
block: Schema.optional(
|
|
93
148
|
Schema.Struct({
|
|
94
149
|
kind: Schema.Literals(kinds),
|
|
95
|
-
props: Schema.Union(
|
|
150
|
+
props: Schema.Union(props)
|
|
96
151
|
})
|
|
97
152
|
),
|
|
98
153
|
closes: Schema.optional(Schema.Array(Schema.String)).annotate({
|
|
@@ -126,9 +181,30 @@ ${catalog}`,
|
|
|
126
181
|
return "sent"
|
|
127
182
|
}
|
|
128
183
|
|
|
129
|
-
|
|
184
|
+
if (block.kind === toolResultKind) {
|
|
185
|
+
const ref = decodeRef(block.props)
|
|
186
|
+
|
|
187
|
+
if (Option.isNone(ref)) {
|
|
188
|
+
return yield* ToolFailure.fail(`A ${toolResultKind} block's props are { resultId }.`)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const result = yield* shownResult(ref.value.resultId, shown)
|
|
192
|
+
|
|
193
|
+
yield* append(
|
|
194
|
+
AssistantEvent.AssistantMessage.make({
|
|
195
|
+
id,
|
|
196
|
+
text,
|
|
197
|
+
block: AssistantEvent.MessageBlock.make({ kind: result.tool, props: {}, resultId: ref.value.resultId }),
|
|
198
|
+
...closed
|
|
199
|
+
})
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
return `sent as message ${id}`
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const schema = kit.byKind.get(block.kind)?.props
|
|
130
206
|
|
|
131
|
-
if (
|
|
207
|
+
if (schema === undefined || Option.isNone(Schema.decodeUnknownOption(schema)(block.props))) {
|
|
132
208
|
return yield* ToolFailure.fail(`Those props don't fit a ${block.kind} block.`)
|
|
133
209
|
}
|
|
134
210
|
|
package/src/internal/turns.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type { ChatId } from "../ChatId.ts"
|
|
|
17
17
|
import * as AssistantEvent from "../AssistantEvent.ts"
|
|
18
18
|
import type * as AssistantLoop from "../AssistantLoop.ts"
|
|
19
19
|
import * as Loop from "./loop.ts"
|
|
20
|
-
import
|
|
20
|
+
import * as BlockKit from "../BlockKit.ts"
|
|
21
21
|
import type { CurrentChat } from "../CurrentChat.ts"
|
|
22
22
|
import * as Instructions from "../Instructions.ts"
|
|
23
23
|
import * as ToolApproval from "../ToolApproval.ts"
|
|
@@ -140,12 +140,13 @@ const approvedCalls = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): Readonl
|
|
|
140
140
|
: []
|
|
141
141
|
)
|
|
142
142
|
|
|
143
|
-
const
|
|
143
|
+
const lastSequence = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, otherwise: number) =>
|
|
144
|
+
entries.at(-1)?.sequence ?? otherwise
|
|
144
145
|
|
|
145
146
|
// The handler's stream can end between a step's output and its tools, or while they run. An interrupted turn (a
|
|
146
147
|
// shutdown) leaves its calls open instead: the next turn runs them again.
|
|
147
|
-
const closeOpenCalls = Effect.fnUntraced(function* (chat: Writer,
|
|
148
|
-
const entries = (yield* chat.entries).filter(({
|
|
148
|
+
const closeOpenCalls = Effect.fnUntraced(function* (chat: Writer, startSequence: number) {
|
|
149
|
+
const entries = (yield* chat.entries).filter(({ sequence }) => sequence > startSequence)
|
|
149
150
|
|
|
150
151
|
const started = new Set(
|
|
151
152
|
entries.flatMap(({ event }) => (Predicate.isTagged(event, "ToolStarted") ? [event.callId] : []))
|
|
@@ -169,18 +170,18 @@ const closeOpenCalls = Effect.fnUntraced(function* (chat: Writer, startSeq: numb
|
|
|
169
170
|
)
|
|
170
171
|
})
|
|
171
172
|
|
|
172
|
-
// The inputs before the turn's `TurnStarted` (at `
|
|
173
|
+
// The inputs before the turn's `TurnStarted` (at `startSequence`) woke it; past that, what its model outputs record
|
|
173
174
|
// reading.
|
|
174
|
-
const handledThrough = (entries: ReadonlyArray<AssistantEvent.AnyEntry>,
|
|
175
|
+
const handledThrough = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, startSequence: number) =>
|
|
175
176
|
entries.reduce(
|
|
176
|
-
(through, {
|
|
177
|
-
|
|
177
|
+
(through, { sequence, event }) =>
|
|
178
|
+
sequence > startSequence &&
|
|
178
179
|
AssistantEvent.isLibraryEvent(event) &&
|
|
179
180
|
Predicate.isTagged(event, "ModelOutput") &&
|
|
180
|
-
event.
|
|
181
|
-
? Math.max(through, event.
|
|
181
|
+
event.seenThroughSequence !== undefined
|
|
182
|
+
? Math.max(through, event.seenThroughSequence)
|
|
182
183
|
: through,
|
|
183
|
-
|
|
184
|
+
startSequence
|
|
184
185
|
)
|
|
185
186
|
|
|
186
187
|
// A turn that ran the loop fails visibly when the user's latest input got no reply; an input that arrived after what
|
|
@@ -190,8 +191,8 @@ const finishTurn = Effect.fnUntraced(function* (chat: Writer, read: number, ranL
|
|
|
190
191
|
|
|
191
192
|
const end =
|
|
192
193
|
ranLoop && History.owesReply(entries, read) && !History.unreadAfter(entries, read)
|
|
193
|
-
? AssistantEvent.TurnFailed.make({
|
|
194
|
-
: AssistantEvent.TurnCompleted.make({
|
|
194
|
+
? AssistantEvent.TurnFailed.make({ throughSequence: read, message: silentTurnMessage })
|
|
195
|
+
: AssistantEvent.TurnCompleted.make({ throughSequence: read })
|
|
195
196
|
|
|
196
197
|
yield* chat.append([end])
|
|
197
198
|
})
|
|
@@ -200,20 +201,21 @@ const finishTurn = Effect.fnUntraced(function* (chat: Writer, read: number, ranL
|
|
|
200
201
|
const failTurn = Effect.fnUntraced(function* (chat: Writer, cause: Cause.Cause<unknown>) {
|
|
201
202
|
yield* Effect.logError("assistant turn failed", cause)
|
|
202
203
|
|
|
203
|
-
const last =
|
|
204
|
+
const last = lastSequence(yield* chat.entries, 0)
|
|
204
205
|
|
|
205
|
-
yield* chat.append([AssistantEvent.TurnFailed.make({
|
|
206
|
+
yield* chat.append([AssistantEvent.TurnFailed.make({ throughSequence: last, message: failureMessage(cause) })])
|
|
206
207
|
})
|
|
207
208
|
|
|
208
209
|
const makeTurnApi = (
|
|
209
210
|
registration: Registration,
|
|
210
211
|
chat: Writer,
|
|
211
|
-
|
|
212
|
+
startSequence: number,
|
|
212
213
|
checkOpen: Effect.Effect<void>,
|
|
213
214
|
ranLoop: Ref.Ref<boolean>
|
|
214
215
|
): Assistant.Turn<any> => {
|
|
215
216
|
const { definition, builtins } = registration
|
|
216
217
|
const { app } = definition
|
|
218
|
+
const shownTools = BlockKit.toolNames(definition.blocks)
|
|
217
219
|
|
|
218
220
|
const running = <A, E, R>(stream: Stream.Stream<A, E, R>) =>
|
|
219
221
|
Stream.unwrap(Ref.set(ranLoop, true).pipe(Effect.as(stream)))
|
|
@@ -227,7 +229,7 @@ const makeTurnApi = (
|
|
|
227
229
|
|
|
228
230
|
const read: AssistantLoop.ChatHistory = {
|
|
229
231
|
messages: History.fromEntries(view(AppEvents.decodeEntries(app, all))),
|
|
230
|
-
|
|
232
|
+
throughSequence: lastSequence(all, startSequence)
|
|
231
233
|
}
|
|
232
234
|
|
|
233
235
|
return read
|
|
@@ -246,7 +248,7 @@ const makeTurnApi = (
|
|
|
246
248
|
Stream.unwrap(
|
|
247
249
|
Effect.gen(function* () {
|
|
248
250
|
const all = yield* chat.entries
|
|
249
|
-
const through = handledThrough(all,
|
|
251
|
+
const through = handledThrough(all, startSequence)
|
|
250
252
|
|
|
251
253
|
// Something arrived after what the loop's model read: the next turn reads it with the full loop, and replies.
|
|
252
254
|
if (History.unreadAfter(all, through)) return Stream.empty
|
|
@@ -276,11 +278,14 @@ const makeTurnApi = (
|
|
|
276
278
|
...History.fromEntries(options.view(AppEvents.decodeEntries(app, entries))),
|
|
277
279
|
History.userText(nudge)
|
|
278
280
|
],
|
|
279
|
-
|
|
281
|
+
throughSequence: lastSequence(entries, startSequence)
|
|
280
282
|
}
|
|
281
283
|
}),
|
|
282
284
|
// A second step, for a reply after a call it was refused.
|
|
283
|
-
maxSteps: 2
|
|
285
|
+
maxSteps: 2,
|
|
286
|
+
// The pass runs because the model wrote text of its own instead of replying, which reaches nobody: it has to
|
|
287
|
+
// call a tool now. Changing the choice costs a read of the history at full price, on these rare turns only.
|
|
288
|
+
toolChoice: "required"
|
|
284
289
|
// The loop ran the approved calls; a pass that can only reply runs none.
|
|
285
290
|
}).pipe(Stream.provideService(Loop.TurnApprovals, Option.none()))
|
|
286
291
|
})
|
|
@@ -296,8 +301,8 @@ const makeTurnApi = (
|
|
|
296
301
|
})
|
|
297
302
|
)
|
|
298
303
|
|
|
299
|
-
const prepare = <T extends Record<string, Tool.Any>>(options: Assistant.
|
|
300
|
-
const own = Toolkits.fromToolkitOrEmpty(options.toolkit)
|
|
304
|
+
const prepare = <T extends Record<string, Tool.Any>>(options: Assistant.RunOptions<T, any>) => {
|
|
305
|
+
const own = Toolkits.showing(Toolkits.fromToolkitOrEmpty(options.toolkit), shownTools)
|
|
301
306
|
|
|
302
307
|
return {
|
|
303
308
|
system: instructions(options.instructions),
|
|
@@ -311,7 +316,7 @@ const makeTurnApi = (
|
|
|
311
316
|
history,
|
|
312
317
|
instructions,
|
|
313
318
|
replyIfOwed,
|
|
314
|
-
|
|
319
|
+
run: (options) => {
|
|
315
320
|
const { system, toolkit, view } = prepare(options)
|
|
316
321
|
const twice = Toolkits.duplicateNames(toolkit)
|
|
317
322
|
|
|
@@ -358,15 +363,15 @@ export const run = Effect.fnUntraced(
|
|
|
358
363
|
)
|
|
359
364
|
])
|
|
360
365
|
|
|
361
|
-
const
|
|
366
|
+
const startSequence = lastSequence(started, 0)
|
|
362
367
|
const ranLoop = yield* Ref.make(false)
|
|
363
|
-
const turn = makeTurnApi(options.registration, chat,
|
|
368
|
+
const turn = makeTurnApi(options.registration, chat, startSequence, options.checkOpen, ranLoop)
|
|
364
369
|
|
|
365
370
|
const record = (event: AssistantLoop.Event) => chat.append([event])
|
|
366
371
|
|
|
367
372
|
yield* options.handler(turn).pipe(
|
|
368
373
|
Stream.runForEach(record),
|
|
369
|
-
Effect.onExit((exit) => (Exit.hasInterrupts(exit) ? Effect.void : closeOpenCalls(chat,
|
|
374
|
+
Effect.onExit((exit) => (Exit.hasInterrupts(exit) ? Effect.void : closeOpenCalls(chat, startSequence))),
|
|
370
375
|
// A loop the app writes gets the assistant's own tools too, with this turn's services.
|
|
371
376
|
Effect.provideService(
|
|
372
377
|
Loop.TurnTools,
|
|
@@ -379,15 +384,16 @@ export const run = Effect.fnUntraced(
|
|
|
379
384
|
onSuccess: Effect.fnUntraced(function* () {
|
|
380
385
|
const all = yield* chat.entries
|
|
381
386
|
|
|
382
|
-
yield* finishTurn(chat, handledThrough(all,
|
|
387
|
+
yield* finishTurn(chat, handledThrough(all, startSequence), yield* Ref.get(ranLoop))
|
|
383
388
|
}),
|
|
384
389
|
onFailure: (cause) => failTurn(chat, cause)
|
|
385
390
|
})
|
|
386
391
|
)
|
|
387
392
|
|
|
388
393
|
// The trace's output is what the user saw this turn, and a failure if any.
|
|
389
|
-
const shown = (yield* chat.entries).flatMap(({ event,
|
|
390
|
-
|
|
394
|
+
const shown = (yield* chat.entries).flatMap(({ event, sequence }) =>
|
|
395
|
+
sequence > startSequence &&
|
|
396
|
+
(Predicate.isTagged(event, "AssistantMessage") || Predicate.isTagged(event, "TurnFailed"))
|
|
391
397
|
? [event]
|
|
392
398
|
: []
|
|
393
399
|
)
|