effect-assistant 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Assistant.d.ts +77 -37
- package/dist/Assistant.d.ts.map +1 -1
- package/dist/Assistant.js +11 -4
- package/dist/Assistant.js.map +1 -1
- package/dist/AssistantActivity.d.ts +10 -16
- package/dist/AssistantActivity.d.ts.map +1 -1
- package/dist/AssistantActivity.js +11 -4
- package/dist/AssistantActivity.js.map +1 -1
- package/dist/AssistantEngine.d.ts +39 -1
- package/dist/AssistantEngine.d.ts.map +1 -1
- package/dist/AssistantEngine.js +81 -28
- package/dist/AssistantEngine.js.map +1 -1
- package/dist/AssistantEvent.d.ts +104 -11
- package/dist/AssistantEvent.d.ts.map +1 -1
- package/dist/AssistantEvent.js +88 -13
- 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/BlockKit.d.ts +4 -4
- package/dist/BlockKit.js +1 -1
- package/dist/BlockKit.js.map +1 -1
- package/dist/ChatLog.js +1 -1
- package/dist/Compaction.d.ts +7 -3
- package/dist/Compaction.d.ts.map +1 -1
- package/dist/Compaction.js +19 -8
- package/dist/Compaction.js.map +1 -1
- package/dist/CurrentChat.d.ts +2 -1
- package/dist/CurrentChat.d.ts.map +1 -1
- package/dist/CurrentChat.js.map +1 -1
- package/dist/Instructions.js +1 -1
- package/dist/Skill.d.ts +86 -11
- package/dist/Skill.d.ts.map +1 -1
- package/dist/Skill.js +93 -18
- package/dist/Skill.js.map +1 -1
- package/dist/Specialist.d.ts +7 -7
- package/dist/Specialist.d.ts.map +1 -1
- package/dist/Specialist.js.map +1 -1
- package/dist/Timeline.d.ts +516 -0
- package/dist/Timeline.d.ts.map +1 -0
- package/dist/Timeline.js +631 -0
- package/dist/Timeline.js.map +1 -0
- 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/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/internal/chat.js +9 -7
- package/dist/internal/chat.js.map +1 -1
- package/dist/internal/events.d.ts +15 -16
- package/dist/internal/events.d.ts.map +1 -1
- package/dist/internal/events.js +29 -28
- package/dist/internal/events.js.map +1 -1
- package/dist/internal/history.js +53 -22
- package/dist/internal/history.js.map +1 -1
- package/dist/internal/input.js +7 -0
- package/dist/internal/input.js.map +1 -0
- package/dist/internal/loop.d.ts +10 -5
- package/dist/internal/loop.d.ts.map +1 -1
- package/dist/internal/loop.js +55 -22
- package/dist/internal/loop.js.map +1 -1
- package/dist/internal/specialists.js +10 -16
- package/dist/internal/specialists.js.map +1 -1
- package/dist/internal/toolkit.d.ts +5 -7
- package/dist/internal/toolkit.d.ts.map +1 -1
- package/dist/internal/toolkit.js +28 -18
- package/dist/internal/toolkit.js.map +1 -1
- package/dist/internal/tools.js +1 -1
- package/dist/internal/turns.d.ts.map +1 -1
- package/dist/internal/turns.js +35 -42
- package/dist/internal/turns.js.map +1 -1
- package/package.json +1 -1
- package/src/Assistant.ts +80 -40
- package/src/AssistantActivity.ts +11 -18
- package/src/AssistantEngine.ts +110 -43
- package/src/AssistantEvent.ts +106 -14
- package/src/AssistantLoop.ts +4 -2
- package/src/BlockKit.ts +6 -6
- package/src/Compaction.ts +25 -4
- package/src/CurrentChat.ts +2 -1
- package/src/Skill.ts +133 -23
- package/src/Specialist.ts +9 -9
- package/src/Timeline.ts +947 -0
- package/src/ToolApproval.ts +249 -0
- package/src/ToolDiscovery.ts +8 -3
- package/src/index.ts +10 -10
- package/src/internal/chat.ts +15 -5
- package/src/internal/events.ts +43 -43
- package/src/internal/history.ts +94 -32
- package/src/internal/input.ts +3 -0
- package/src/internal/loop.ts +120 -34
- package/src/internal/specialists.ts +7 -12
- package/src/internal/toolkit.ts +47 -30
- package/src/internal/turns.ts +52 -58
- package/dist/AppEvent.d.ts +0 -111
- package/dist/AppEvent.d.ts.map +0 -1
- package/dist/AppEvent.js +0 -71
- package/dist/AppEvent.js.map +0 -1
- package/dist/ChatView.d.ts +0 -408
- package/dist/ChatView.d.ts.map +0 -1
- package/dist/ChatView.js +0 -443
- package/dist/ChatView.js.map +0 -1
- package/dist/internal/skills.js +0 -28
- package/dist/internal/skills.js.map +0 -1
- package/src/AppEvent.ts +0 -134
- package/src/ChatView.ts +0 -587
- package/src/internal/skills.ts +0 -37
package/src/internal/history.ts
CHANGED
|
@@ -110,16 +110,53 @@ const answerText = (answer: AssistantEvent.BlockAnswer) =>
|
|
|
110
110
|
const resultText = (result: AssistantEvent.ToolResult) =>
|
|
111
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
112
|
|
|
113
|
-
|
|
113
|
+
/** @internal */
|
|
114
|
+
export const awaitingApprovalOutput =
|
|
115
|
+
"Waiting for the user's approval: they see this call and approve or reject it. Its outcome arrives later in this chat; don't call it again meanwhile."
|
|
116
|
+
|
|
117
|
+
const awaitingText = (request: AssistantEvent.ApprovalRequested) =>
|
|
118
|
+
`<approval tool="${request.tool}" call="${request.callId}" status="pending">\nYour call to ${request.tool} from earlier in this chat waits for the user's approval.\n${toJsonText(request.params)}\n</approval>`
|
|
119
|
+
|
|
120
|
+
const approvedText = (result: AssistantEvent.ToolResult) =>
|
|
121
|
+
`<result tool="${result.tool}" call="${result.callId}" outcome="${result.outcome}">\nThe user approved your call to ${result.tool} from earlier in this chat, and it ran.\n${toJsonText(result.result)}\n</result>`
|
|
122
|
+
|
|
123
|
+
const rejectedText = (decision: AssistantEvent.ApprovalDecision, tool: string) =>
|
|
124
|
+
`<approval tool="${tool}" call="${decision.callId}" decision="rejected">\nThe user rejected your call to ${tool}; it didn't run.${decision.reason === undefined ? "" : `\nTheir reason: ${decision.reason}`}\n</approval>`
|
|
125
|
+
|
|
126
|
+
const expiredText = (callId: string, tool: string) =>
|
|
127
|
+
`<approval tool="${tool}" call="${callId}" status="expired">\nThe user moved on without approving your call to ${tool}; it didn't run.\n</approval>`
|
|
128
|
+
|
|
129
|
+
const awaitingMessage = (request: AssistantEvent.ApprovalRequested): Prompt.ToolMessage =>
|
|
130
|
+
Prompt.toolMessage({
|
|
131
|
+
content: [
|
|
132
|
+
Prompt.toolResultPart({
|
|
133
|
+
id: request.callId,
|
|
134
|
+
name: request.tool,
|
|
135
|
+
isFailure: false,
|
|
136
|
+
result: awaitingApprovalOutput,
|
|
137
|
+
providerExecuted: false
|
|
138
|
+
})
|
|
139
|
+
]
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const isEntry = (item: AssistantEvent.AnyEntry | Prompt.Message): item is AssistantEvent.AnyEntry =>
|
|
143
|
+
Predicate.hasProperty(item, "seq")
|
|
144
|
+
|
|
145
|
+
// In the order the model saw the entries, not the order they were stored. A message a history view put in stands
|
|
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
|
|
147
|
+
// read; the app's own events reach the model only as such messages.
|
|
114
148
|
/** @internal */
|
|
115
149
|
export const fromEntries = (
|
|
116
|
-
|
|
117
|
-
readApp: (event: { readonly _tag: string }) => string | undefined
|
|
150
|
+
items: ReadonlyArray<AssistantEvent.AnyEntry | Prompt.Message>
|
|
118
151
|
): ReadonlyArray<Prompt.Message> => {
|
|
152
|
+
const entries = items.filter(isEntry)
|
|
119
153
|
const history: Array<Prompt.Message> = []
|
|
120
154
|
const held: Array<Prompt.Message> = []
|
|
121
155
|
const open = new Set<string>()
|
|
122
156
|
const called = new Set<string>()
|
|
157
|
+
// Calls that waited for approval: the placeholder answered them, so what came of them later is an input.
|
|
158
|
+
const requests = new Map<string, AssistantEvent.ApprovalRequested>()
|
|
159
|
+
const toolOf = (callId: string) => requests.get(callId)?.tool ?? "a tool"
|
|
123
160
|
|
|
124
161
|
const flushIfClosed = () => {
|
|
125
162
|
if (open.size === 0) history.push(...held.splice(0))
|
|
@@ -133,27 +170,35 @@ export const fromEntries = (
|
|
|
133
170
|
: undefined
|
|
134
171
|
}
|
|
135
172
|
|
|
136
|
-
|
|
173
|
+
// `index` is the last entry at or before the item, and `seq` where the item stands.
|
|
174
|
+
const input = (index: number, seq: number, item: Prompt.Message) => {
|
|
137
175
|
const seen = nextSeen(index)
|
|
138
176
|
|
|
139
|
-
if (open.size > 0 || (seen !== undefined && seen <
|
|
177
|
+
if (open.size > 0 || (seen !== undefined && seen < seq)) held.push(item)
|
|
140
178
|
else history.push(item)
|
|
141
179
|
}
|
|
142
180
|
|
|
143
|
-
|
|
144
|
-
if (!AssistantEvent.isLibraryEvent(event)) {
|
|
145
|
-
const text = readApp(event)
|
|
181
|
+
let index = -1
|
|
146
182
|
|
|
147
|
-
|
|
183
|
+
for (const item of items) {
|
|
184
|
+
if (!isEntry(item)) {
|
|
185
|
+
input(index, (entries[index]?.seq ?? 0) + 0.5, item)
|
|
148
186
|
|
|
149
|
-
|
|
187
|
+
continue
|
|
150
188
|
}
|
|
151
189
|
|
|
190
|
+
index += 1
|
|
191
|
+
|
|
192
|
+
const { event, seq } = item
|
|
193
|
+
const at = index
|
|
194
|
+
|
|
195
|
+
if (!AssistantEvent.isLibraryEvent(event)) continue
|
|
196
|
+
|
|
152
197
|
Match.value(event).pipe(
|
|
153
198
|
Match.tagsExhaustive({
|
|
154
199
|
UserMessage: (message) =>
|
|
155
|
-
input(
|
|
156
|
-
BlockAnswer: (answer) => input(
|
|
200
|
+
input(at, seq, userText(AssistantEvent.textWithAttachments(message.text, message.attachments))),
|
|
201
|
+
BlockAnswer: (answer) => input(at, seq, userText(answerText(answer))),
|
|
157
202
|
ModelOutput: (output) => {
|
|
158
203
|
history.push(...Prompt.fromResponseParts(output.parts).content)
|
|
159
204
|
|
|
@@ -167,9 +212,11 @@ export const fromEntries = (
|
|
|
167
212
|
flushIfClosed()
|
|
168
213
|
},
|
|
169
214
|
ToolResult: (result) => {
|
|
215
|
+
if (requests.has(result.callId)) return input(at, seq, userText(approvedText(result)))
|
|
216
|
+
|
|
170
217
|
// A view that starts after the call (since a summary, say) keeps its result as text: a tool result needs its
|
|
171
218
|
// call right before it.
|
|
172
|
-
if (!called.has(result.callId)) return input(
|
|
219
|
+
if (!called.has(result.callId)) return input(at, seq, userText(resultText(result)))
|
|
173
220
|
|
|
174
221
|
history.push(toolMessage(result))
|
|
175
222
|
open.delete(result.callId)
|
|
@@ -180,21 +227,38 @@ export const fromEntries = (
|
|
|
180
227
|
TurnStarted: () => undefined,
|
|
181
228
|
TurnCompleted: () => undefined,
|
|
182
229
|
TurnFailed: () => undefined,
|
|
183
|
-
SpecialistFinished: (finished) => input(
|
|
230
|
+
SpecialistFinished: (finished) => input(at, seq, userText(finishedText(entries, at, finished))),
|
|
184
231
|
// Every run starts in a call of the model's own (a spawn or a follow-up), whose result says so.
|
|
185
232
|
SpecialistStarted: () => undefined,
|
|
186
233
|
BlocksClosed: () => undefined,
|
|
234
|
+
// The call's result, for the provider's pairing; what the user decides comes later.
|
|
235
|
+
ApprovalRequested: (request) => {
|
|
236
|
+
requests.set(request.callId, request)
|
|
237
|
+
|
|
238
|
+
if (!called.has(request.callId)) return input(at, seq, userText(awaitingText(request)))
|
|
239
|
+
|
|
240
|
+
history.push(awaitingMessage(request))
|
|
241
|
+
open.delete(request.callId)
|
|
242
|
+
flushIfClosed()
|
|
243
|
+
},
|
|
244
|
+
// An approved call's result says so.
|
|
245
|
+
ApprovalDecision: (decision) =>
|
|
246
|
+
decision.decision === "rejected"
|
|
247
|
+
? input(at, seq, userText(rejectedText(decision, toolOf(decision.callId))))
|
|
248
|
+
: undefined,
|
|
249
|
+
ApprovalsExpired: (expired) =>
|
|
250
|
+
expired.callIds.forEach((callId) => input(at, seq, userText(expiredText(callId, toolOf(callId))))),
|
|
187
251
|
ChatDeleted: () => undefined
|
|
188
252
|
})
|
|
189
253
|
)
|
|
190
|
-
}
|
|
254
|
+
}
|
|
191
255
|
|
|
192
256
|
return [...history, ...held]
|
|
193
257
|
}
|
|
194
258
|
|
|
195
259
|
/** @internal */
|
|
196
|
-
export const isInput = (event: { readonly _tag: string }
|
|
197
|
-
AssistantEvent.isInput(event) ||
|
|
260
|
+
export const isInput = (event: { readonly _tag: string }): boolean =>
|
|
261
|
+
AssistantEvent.isInput(event) || AssistantEvent.isAppInput(event)
|
|
198
262
|
|
|
199
263
|
/** @internal */
|
|
200
264
|
export const handledThrough = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): number => {
|
|
@@ -206,23 +270,20 @@ export const handledThrough = (entries: ReadonlyArray<AssistantEvent.AnyEntry>):
|
|
|
206
270
|
// A run the assistant stopped isn't an input here: it wakes nothing.
|
|
207
271
|
/** @internal */
|
|
208
272
|
export const unhandledInputs = (
|
|
209
|
-
entries: ReadonlyArray<AssistantEvent.AnyEntry
|
|
210
|
-
wakes: ReadonlySet<string>
|
|
273
|
+
entries: ReadonlyArray<AssistantEvent.AnyEntry>
|
|
211
274
|
): ReadonlyArray<{ readonly _tag: string }> => {
|
|
212
275
|
const seen = handledThrough(entries)
|
|
213
276
|
|
|
214
277
|
return entries.flatMap(({ event, seq }) =>
|
|
215
|
-
seq > seen && isInput(event
|
|
216
|
-
? [event]
|
|
217
|
-
: []
|
|
278
|
+
seq > seen && isInput(event) && !(AssistantEvent.isInput(event) && !AssistantEvent.isWaking(event)) ? [event] : []
|
|
218
279
|
)
|
|
219
280
|
}
|
|
220
281
|
|
|
221
282
|
/** @internal */
|
|
222
|
-
export const needsTurn = (entries: ReadonlyArray<AssistantEvent.AnyEntry
|
|
223
|
-
unhandledInputs(entries
|
|
283
|
+
export const needsTurn = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): boolean =>
|
|
284
|
+
unhandledInputs(entries).length > 0
|
|
224
285
|
|
|
225
|
-
// A failure the user was shown counts as a reply.
|
|
286
|
+
// A failure the user was shown counts as a reply, and so does a call they're asked to approve.
|
|
226
287
|
/** @internal */
|
|
227
288
|
export const owesReply = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, throughSeq: number): boolean => {
|
|
228
289
|
const latest = entries.findLast(
|
|
@@ -233,7 +294,10 @@ export const owesReply = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, throu
|
|
|
233
294
|
latest !== undefined &&
|
|
234
295
|
!entries.some(
|
|
235
296
|
({ event, seq }) =>
|
|
236
|
-
seq > latest.seq &&
|
|
297
|
+
seq > latest.seq &&
|
|
298
|
+
(Predicate.isTagged(event, "AssistantMessage") ||
|
|
299
|
+
Predicate.isTagged(event, "TurnFailed") ||
|
|
300
|
+
Predicate.isTagged(event, "ApprovalRequested"))
|
|
237
301
|
)
|
|
238
302
|
)
|
|
239
303
|
}
|
|
@@ -278,17 +342,15 @@ export const owesDelivery = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, th
|
|
|
278
342
|
)
|
|
279
343
|
)
|
|
280
344
|
|
|
345
|
+
// A call waiting for the user's approval is what comes next, like a specialist's report.
|
|
281
346
|
return (
|
|
282
347
|
messages.length > 0 &&
|
|
283
348
|
messages.every((message) => message.block === undefined && progress.has(message.id)) &&
|
|
284
|
-
!after.some((event) => Predicate.isTagged(event, "TurnFailed")) &&
|
|
349
|
+
!after.some((event) => Predicate.isTagged(event, "TurnFailed") || Predicate.isTagged(event, "ApprovalRequested")) &&
|
|
285
350
|
started.every((runId) => finished.has(runId))
|
|
286
351
|
)
|
|
287
352
|
}
|
|
288
353
|
|
|
289
354
|
/** @internal */
|
|
290
|
-
export const unreadAfter = (
|
|
291
|
-
entries
|
|
292
|
-
throughSeq: number,
|
|
293
|
-
wakes: ReadonlySet<string>
|
|
294
|
-
): boolean => entries.some(({ event, seq }) => seq > throughSeq && isInput(event, wakes))
|
|
355
|
+
export const unreadAfter = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, throughSeq: number): boolean =>
|
|
356
|
+
entries.some(({ event, seq }) => seq > throughSeq && isInput(event))
|
package/src/internal/loop.ts
CHANGED
|
@@ -21,7 +21,18 @@ import * as Toolkits from "./toolkit.ts"
|
|
|
21
21
|
import { isTurnEnding } from "./tools.ts"
|
|
22
22
|
|
|
23
23
|
/** @internal */
|
|
24
|
-
export type Event =
|
|
24
|
+
export type Event =
|
|
25
|
+
| AssistantEvent.ModelOutput
|
|
26
|
+
| AssistantEvent.ToolStarted
|
|
27
|
+
| AssistantEvent.ToolResult
|
|
28
|
+
| AssistantEvent.ApprovalRequested
|
|
29
|
+
|
|
30
|
+
// Only a turn's loop can wait for the user's approval, and runs the calls they approved. A turn's specialists inherit
|
|
31
|
+
// its context, so their runs set it back to none.
|
|
32
|
+
/** @internal */
|
|
33
|
+
export const TurnApprovals = Context.Reference<
|
|
34
|
+
Option.Option<{ readonly approved: Effect.Effect<ReadonlyArray<AssistantEvent.ToolCallPart>> }>
|
|
35
|
+
>("effect-assistant/TurnApprovals", { defaultValue: Option.none })
|
|
25
36
|
|
|
26
37
|
// 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
38
|
// specialist's run has none.
|
|
@@ -61,8 +72,9 @@ export interface StepResult {
|
|
|
61
72
|
/** The model's output; unset when the step only ran calls a crash cut off. */
|
|
62
73
|
readonly output: AssistantEvent.ModelOutput | undefined
|
|
63
74
|
/**
|
|
64
|
-
* Whether the model is done: it made no tool calls,
|
|
65
|
-
* with `wait: true`) and none of the step's
|
|
75
|
+
* Whether the model is done: it made no tool calls, one of its calls waits for the user's
|
|
76
|
+
* approval, or it ended its turn (`wait`, or a message with `wait: true`) and none of the step's
|
|
77
|
+
* calls failed.
|
|
66
78
|
*/
|
|
67
79
|
readonly done: boolean
|
|
68
80
|
/**
|
|
@@ -71,9 +83,15 @@ export interface StepResult {
|
|
|
71
83
|
readonly historyLength: number
|
|
72
84
|
}
|
|
73
85
|
|
|
74
|
-
// The loop runs the calls itself, after storing each as started, so the model call runs none.
|
|
86
|
+
// The loop runs the calls itself, after storing each as started, so the model call runs none. It also decides which
|
|
87
|
+
// calls wait for approval: the model call would ask for approvals of its own.
|
|
75
88
|
const requestToolkit = (definitions: ReadonlyArray<Tool.Any>): Toolkit.WithHandler<Record<string, Tool.Any>> => ({
|
|
76
|
-
tools: Object.fromEntries(
|
|
89
|
+
tools: Object.fromEntries(
|
|
90
|
+
definitions.map((tool) => [
|
|
91
|
+
tool.name,
|
|
92
|
+
tool.needsApproval === undefined || tool.needsApproval === false ? tool : tool.setNeedsApproval(false)
|
|
93
|
+
])
|
|
94
|
+
),
|
|
77
95
|
handle: () => Effect.succeed(Stream.empty)
|
|
78
96
|
})
|
|
79
97
|
|
|
@@ -111,28 +129,79 @@ const runCall = Effect.fnUntraced(function* <R>(tools: Toolkits.Tools<R>, call:
|
|
|
111
129
|
return AssistantEvent.ToolResult.make({ callId: call.id, tool: call.name, ...result })
|
|
112
130
|
})
|
|
113
131
|
|
|
114
|
-
//
|
|
115
|
-
|
|
132
|
+
// A call that needs approval waits for the user in a turn. A specialist's run can't ask them, so its call fails.
|
|
133
|
+
const awaitApproval = Effect.fnUntraced(function* (call: AssistantEvent.ToolCallPart) {
|
|
134
|
+
if (Option.isNone(yield* TurnApprovals)) {
|
|
135
|
+
return AssistantEvent.ToolResult.make({
|
|
136
|
+
callId: call.id,
|
|
137
|
+
tool: call.name,
|
|
138
|
+
...Toolkits.failed(
|
|
139
|
+
`${call.name} needs the user's approval, and only the assistant can ask for it. Don't run it: say in your answer what needs approving, with the details.`,
|
|
140
|
+
"NeedsApproval"
|
|
141
|
+
)
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return AssistantEvent.ApprovalRequested.make({ callId: call.id, tool: call.name, params: call.params })
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
interface Pending {
|
|
149
|
+
readonly call: AssistantEvent.ToolCallPart
|
|
150
|
+
/** The user approved it: it runs without asking again. */
|
|
151
|
+
readonly approved: boolean
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface Ran {
|
|
155
|
+
/** One of the calls failed, so the model has to read the results. */
|
|
156
|
+
readonly failed: boolean
|
|
157
|
+
/** One of the calls waits for the user's approval, so the turn ends. */
|
|
158
|
+
readonly waiting: boolean
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Every `ToolStarted` is stored before any call runs; a call that waits for approval doesn't start.
|
|
116
162
|
const runCalls = <R, S, E, R2>(
|
|
117
163
|
tools: Toolkits.Tools<R>,
|
|
118
|
-
|
|
119
|
-
|
|
164
|
+
pending: ReadonlyArray<Pending>,
|
|
165
|
+
history: ReadonlyArray<Prompt.Message>,
|
|
166
|
+
then: (ran: Ran) => Stream.Stream<Step<never, S>, E, R2>
|
|
120
167
|
): Stream.Stream<Step<Event, S>, E, R | R2> =>
|
|
121
|
-
Stream.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
})
|
|
168
|
+
Stream.unwrap(
|
|
169
|
+
Effect.gen(function* () {
|
|
170
|
+
const waits = yield* Effect.forEach(pending, ({ call, approved }) =>
|
|
171
|
+
approved ? Effect.succeed(false) : tools.needsApproval(call, history)
|
|
131
172
|
)
|
|
132
|
-
|
|
173
|
+
|
|
174
|
+
const started = pending.flatMap(({ call }, i) =>
|
|
175
|
+
waits[i] === true ? [] : [value(AssistantEvent.ToolStarted.make({ callId: call.id, tool: call.name }))]
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return Stream.fromIterable(started).pipe(
|
|
179
|
+
Stream.concat(
|
|
180
|
+
Stream.unwrap(
|
|
181
|
+
Effect.gen(function* () {
|
|
182
|
+
const done = yield* Effect.forEach(
|
|
183
|
+
pending,
|
|
184
|
+
({ call }, i): Effect.Effect<Event, never, R> =>
|
|
185
|
+
waits[i] === true ? awaitApproval(call) : runCall(tools, call),
|
|
186
|
+
{ concurrency: "unbounded" }
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return Stream.fromIterable(done.map(value)).pipe(
|
|
190
|
+
Stream.concat(
|
|
191
|
+
then({
|
|
192
|
+
failed: done.some(isFailure),
|
|
193
|
+
waiting: done.some((event) => Predicate.isTagged(event, "ApprovalRequested"))
|
|
194
|
+
})
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
})
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
})
|
|
133
202
|
)
|
|
134
203
|
|
|
135
|
-
const isFailure = (
|
|
204
|
+
const isFailure = (event: Event) => Predicate.isTagged(event, "ToolResult") && event.outcome === "failure"
|
|
136
205
|
|
|
137
206
|
/** @internal */
|
|
138
207
|
export type Step<A, S> = Data.TaggedEnum<{
|
|
@@ -192,19 +261,31 @@ export const step = <R, S, E2 = never, R2 = never>(
|
|
|
192
261
|
const instructions = Predicate.isString(options.instructions) ? { text: options.instructions } : options.instructions
|
|
193
262
|
|
|
194
263
|
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
264
|
|
|
199
265
|
return Stream.unwrap(
|
|
200
266
|
Effect.gen(function* () {
|
|
201
267
|
const { messages: history, throughSeq } = readHistory(yield* options.history)
|
|
202
268
|
// Calls a crash cut off, with no result: they run again, with the same ids, before the model goes on.
|
|
203
269
|
const cutOff = History.unansweredCalls(history)
|
|
270
|
+
const approvals = yield* TurnApprovals
|
|
271
|
+
|
|
272
|
+
// Calls the user approved: they run as the model made them, with the same ids, before the model goes on. Not in
|
|
273
|
+
// a loop without their tools.
|
|
274
|
+
const approved = Option.isNone(approvals)
|
|
275
|
+
? []
|
|
276
|
+
: (yield* approvals.value.approved).filter(
|
|
277
|
+
(call) =>
|
|
278
|
+
toolkit.definitions.some((tool) => tool.name === call.name) && !cutOff.some((cut) => cut.id === call.id)
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
if (cutOff.length > 0 || approved.length > 0) {
|
|
282
|
+
const pending = [
|
|
283
|
+
...cutOff.map((call) => ({ call, approved: false })),
|
|
284
|
+
...approved.map((call) => ({ call, approved: true }))
|
|
285
|
+
]
|
|
204
286
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
after({ output: undefined, done: false, historyLength: history.length + cutOff.length })
|
|
287
|
+
return runCalls(toolkit, pending, history, ({ waiting }) =>
|
|
288
|
+
after({ output: undefined, done: waiting, historyLength: history.length + pending.length })
|
|
208
289
|
)
|
|
209
290
|
}
|
|
210
291
|
|
|
@@ -230,15 +311,20 @@ export const step = <R, S, E2 = never, R2 = never>(
|
|
|
230
311
|
const calls = toolCalls(output.parts)
|
|
231
312
|
const historyLength = history.length + Prompt.fromResponseParts(output.parts).content.length + calls.length
|
|
232
313
|
|
|
233
|
-
// A failed call doesn't end the turn: a message that failed never reached the user, so the model reads why.
|
|
314
|
+
// A failed call doesn't end the turn: a message that failed never reached the user, so the model reads why. A call
|
|
315
|
+
// that waits for approval does: the turn waits for the user.
|
|
234
316
|
return Stream.make(value(output)).pipe(
|
|
235
317
|
Stream.concat(
|
|
236
|
-
runCalls(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
318
|
+
runCalls(
|
|
319
|
+
toolkit,
|
|
320
|
+
calls.map((call) => ({ call, approved: false })),
|
|
321
|
+
history,
|
|
322
|
+
({ failed, waiting }) =>
|
|
323
|
+
after({
|
|
324
|
+
output,
|
|
325
|
+
done: calls.length === 0 || waiting || (!failed && calls.some(isTurnEnding)),
|
|
326
|
+
historyLength
|
|
327
|
+
})
|
|
242
328
|
)
|
|
243
329
|
)
|
|
244
330
|
)
|
|
@@ -22,12 +22,9 @@ import * as Loop from "./loop.ts"
|
|
|
22
22
|
import { CurrentChat } from "../CurrentChat.ts"
|
|
23
23
|
import * as InputId from "../InputId.ts"
|
|
24
24
|
import * as RunId from "../RunId.ts"
|
|
25
|
-
import * as Skill from "../Skill.ts"
|
|
26
|
-
import * as Skills from "./skills.ts"
|
|
27
25
|
import * as Specialist from "../Specialist.ts"
|
|
28
26
|
import * as ToolFailure from "../ToolFailure.ts"
|
|
29
27
|
import { toolMessage, userText } from "./history.ts"
|
|
30
|
-
import { mapText } from "./instructions.ts"
|
|
31
28
|
import * as Telemetry from "./telemetry.ts"
|
|
32
29
|
import * as Toolkits from "./toolkit.ts"
|
|
33
30
|
import type { Writer } from "./writer.ts"
|
|
@@ -83,21 +80,19 @@ export const runBrief = Effect.fnUntraced(
|
|
|
83
80
|
chatId: options.chatId,
|
|
84
81
|
brief: options.brief,
|
|
85
82
|
history: current,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
toolkit: Toolkits.merge(Skills.toTools(skills), Toolkits.fromToolkitOrEmpty(loop.toolkit)),
|
|
83
|
+
// A run can't wait for the user's approval: a call that needs it fails, and the run reads why.
|
|
84
|
+
loop: (loop) =>
|
|
85
|
+
Loop.run({
|
|
86
|
+
instructions: loop.instructions,
|
|
87
|
+
toolkit: Toolkits.fromToolkitOrEmpty(loop.toolkit),
|
|
92
88
|
history: current,
|
|
93
89
|
maxSteps: loop.maxSteps ?? 8
|
|
94
|
-
})
|
|
95
|
-
}
|
|
90
|
+
}).pipe(Stream.provideService(Loop.TurnApprovals, Option.none()))
|
|
96
91
|
}
|
|
97
92
|
|
|
98
93
|
// A run lives in memory, so there's nothing to run again after a crash: `ToolStarted` isn't kept.
|
|
99
94
|
const record = Effect.fnUntraced(function* (event: AssistantLoop.Event) {
|
|
100
|
-
if (Predicate.isTagged(event, "ToolStarted")) return
|
|
95
|
+
if (Predicate.isTagged(event, "ToolStarted") || Predicate.isTagged(event, "ApprovalRequested")) return
|
|
101
96
|
|
|
102
97
|
if (Predicate.isTagged(event, "ToolResult")) return yield* add([toolMessage(event)])
|
|
103
98
|
|
package/src/internal/toolkit.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import * as AiError from "effect/ai/AiError"
|
|
2
|
+
import type * as Prompt from "effect/ai/Prompt"
|
|
2
3
|
import type * as Tool from "effect/ai/Tool"
|
|
3
4
|
import type * as Toolkit from "effect/ai/Toolkit"
|
|
4
5
|
import type * as Context from "effect/Context"
|
|
5
6
|
import * as Effect from "effect/Effect"
|
|
6
7
|
import * as Option from "effect/Option"
|
|
8
|
+
import * as Predicate from "effect/Predicate"
|
|
7
9
|
import * as Schema from "effect/Schema"
|
|
8
10
|
import * as Stream from "effect/Stream"
|
|
9
11
|
import type * as AssistantEvent from "../AssistantEvent.ts"
|
|
@@ -23,13 +25,18 @@ export interface CallResult {
|
|
|
23
25
|
export interface Tools<out R = never> {
|
|
24
26
|
readonly definitions: ReadonlyArray<Tool.Any>
|
|
25
27
|
readonly run: (call: AssistantEvent.ToolCallPart) => Effect.Effect<CallResult, never, R>
|
|
28
|
+
// Whether the call waits for the user's approval instead of running: its tool's `needsApproval`.
|
|
29
|
+
readonly needsApproval: (
|
|
30
|
+
call: AssistantEvent.ToolCallPart,
|
|
31
|
+
history: ReadonlyArray<Prompt.Message>
|
|
32
|
+
) => Effect.Effect<boolean, never, R>
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
/** @internal */
|
|
29
|
-
export type ToolServices<T extends Record<string, Tool.Any>> =
|
|
30
|
-
Tool.HandlerServices<T[keyof T]>,
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
export type ToolServices<T extends Record<string, Tool.Any>> =
|
|
37
|
+
| Exclude<Tool.HandlerServices<T[keyof T]>, AssistantToolCall>
|
|
38
|
+
// Already in the handlers' services; stated for the loop, which decodes params to ask `needsApproval`.
|
|
39
|
+
| T[keyof T]["parametersSchema"]["DecodingServices"]
|
|
33
40
|
|
|
34
41
|
const encodeFailure = Schema.encodeSync(ToolFailure.ToolFailure)
|
|
35
42
|
|
|
@@ -60,15 +67,32 @@ const hasTool = <T extends Record<string, Tool.Any>>(
|
|
|
60
67
|
name: PropertyKey
|
|
61
68
|
): name is keyof T => Object.hasOwn(toolkit.tools, name)
|
|
62
69
|
|
|
63
|
-
// Approval would park a call until the user decides, which this library doesn't do: the model calls a tool with
|
|
64
|
-
// final values, and a user's own commitment goes through the app's API from a block.
|
|
65
70
|
/** @internal */
|
|
66
71
|
export const needingApproval = (definitions: ReadonlyArray<Tool.Any>): ReadonlyArray<string> =>
|
|
67
72
|
definitions.flatMap((tool) => (tool.needsApproval === undefined || tool.needsApproval === false ? [] : [tool.name]))
|
|
68
73
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
// With noUncheckedIndexedAccess, an index doesn't narrow on `hasTool`.
|
|
75
|
+
const toolNamed = (toolkit: { readonly tools: Record<string, Tool.Any> }, name: string): Tool.Any | undefined =>
|
|
76
|
+
Object.hasOwn(toolkit.tools, name) ? toolkit.tools[name] : undefined
|
|
77
|
+
|
|
78
|
+
// Params that don't fit the tool don't wait: the call runs, and fails with why.
|
|
79
|
+
const needsApprovalNative = Effect.fnUntraced(function* <T extends Record<string, Tool.Any>>(
|
|
80
|
+
toolkit: { readonly tools: T },
|
|
81
|
+
call: AssistantEvent.ToolCallPart,
|
|
82
|
+
history: ReadonlyArray<Prompt.Message>
|
|
83
|
+
): Effect.fn.Return<boolean, never, ToolServices<T>> {
|
|
84
|
+
const tool = toolNamed(toolkit, call.name)
|
|
85
|
+
const needs = tool?.needsApproval
|
|
86
|
+
|
|
87
|
+
if (tool === undefined || needs === undefined || Predicate.isBoolean(needs)) return needs === true
|
|
88
|
+
|
|
89
|
+
return yield* Effect.gen(function* () {
|
|
90
|
+
const params = yield* Schema.decodeUnknownEffect(tool.parametersSchema)(call.params)
|
|
91
|
+
const decided = needs(params, { toolCallId: call.id, messages: history })
|
|
92
|
+
|
|
93
|
+
return Effect.isEffect(decided) ? yield* decided : decided
|
|
94
|
+
}).pipe(Effect.orElseSucceed(() => false))
|
|
95
|
+
})
|
|
72
96
|
|
|
73
97
|
// The toolkit decodes the params with the tool's schema before its handler sees them, and a call whose params don't
|
|
74
98
|
// fit fails with a validation error the model reads: the model's JSON is what `handle` takes.
|
|
@@ -117,7 +141,8 @@ export const fromWithHandler = <T extends Record<string, Tool.Any>>(
|
|
|
117
141
|
toolkit: Toolkit.WithHandler<T>
|
|
118
142
|
): Tools<ToolServices<T>> => ({
|
|
119
143
|
definitions: Object.values(toolkit.tools),
|
|
120
|
-
run: (call) => runNative(toolkit, call)
|
|
144
|
+
run: (call) => runNative(toolkit, call),
|
|
145
|
+
needsApproval: (call, history) => needsApprovalNative(toolkit, call, history)
|
|
121
146
|
})
|
|
122
147
|
|
|
123
148
|
/** @internal */
|
|
@@ -127,22 +152,8 @@ export const fromToolkit = <T extends Record<string, Tool.Any>>(
|
|
|
127
152
|
definitions: Object.values(toolkit.tools),
|
|
128
153
|
run: Effect.fnUntraced(function* (call) {
|
|
129
154
|
return yield* runNative(yield* toolkit, call)
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
// The handlers use services of the loop they run in, so they're made for each call, with its context.
|
|
134
|
-
/** @internal */
|
|
135
|
-
export const withContext = <T extends Record<string, Tool.Any>, R>(
|
|
136
|
-
toolkit: Toolkit.Toolkit<T>,
|
|
137
|
-
handlers: (context: Context.Context<R>) => Toolkit.HandlersFrom<T>
|
|
138
|
-
): Tools<R | ToolServices<T>> => ({
|
|
139
|
-
definitions: Object.values(toolkit.tools),
|
|
140
|
-
run: Effect.fnUntraced(function* (call) {
|
|
141
|
-
const context = yield* Effect.context<R>()
|
|
142
|
-
const resolved = yield* Effect.provideContext(toolkit, yield* toolkit.toHandlers(handlers(context)))
|
|
143
|
-
|
|
144
|
-
return yield* runNative(resolved, call)
|
|
145
|
-
})
|
|
155
|
+
}),
|
|
156
|
+
needsApproval: (call, history) => needsApprovalNative(toolkit, call, history)
|
|
146
157
|
})
|
|
147
158
|
|
|
148
159
|
/** @internal */
|
|
@@ -153,7 +164,8 @@ export const fromToolkitOrEmpty = <T extends Record<string, Tool.Any>>(
|
|
|
153
164
|
/** @internal */
|
|
154
165
|
export const empty: Tools = {
|
|
155
166
|
definitions: [],
|
|
156
|
-
run: (call) => Effect.succeed(failed(`No tool named ${call.name}.`, "ToolNotFoundError"))
|
|
167
|
+
run: (call) => Effect.succeed(failed(`No tool named ${call.name}.`, "ToolNotFoundError")),
|
|
168
|
+
needsApproval: () => Effect.succeed(false)
|
|
157
169
|
}
|
|
158
170
|
|
|
159
171
|
/** @internal */
|
|
@@ -162,7 +174,9 @@ export const merge = <A, B>(self: Tools<A>, that: Tools<B>): Tools<A | B> => {
|
|
|
162
174
|
|
|
163
175
|
return {
|
|
164
176
|
definitions: [...self.definitions, ...that.definitions],
|
|
165
|
-
run: (call) => (inSelf(call) ? self.run(call) : that.run(call))
|
|
177
|
+
run: (call) => (inSelf(call) ? self.run(call) : that.run(call)),
|
|
178
|
+
needsApproval: (call, history) =>
|
|
179
|
+
inSelf(call) ? self.needsApproval(call, history) : that.needsApproval(call, history)
|
|
166
180
|
}
|
|
167
181
|
}
|
|
168
182
|
|
|
@@ -170,7 +184,8 @@ export const merge = <A, B>(self: Tools<A>, that: Tools<B>): Tools<A | B> => {
|
|
|
170
184
|
/** @internal */
|
|
171
185
|
export const provide = <R>(tools: Tools<R>, context: Context.Context<R>): Tools => ({
|
|
172
186
|
definitions: tools.definitions,
|
|
173
|
-
run: (call) => Effect.provideContext(tools.run(call), context)
|
|
187
|
+
run: (call) => Effect.provideContext(tools.run(call), context),
|
|
188
|
+
needsApproval: (call, history) => Effect.provideContext(tools.needsApproval(call, history), context)
|
|
174
189
|
})
|
|
175
190
|
|
|
176
191
|
/** @internal */
|
|
@@ -184,5 +199,7 @@ export const duplicateNames = (tools: Tools<unknown>): ReadonlyArray<string> =>
|
|
|
184
199
|
/** @internal */
|
|
185
200
|
export const runOnly = <R>(tools: Tools<R>, names: ReadonlyArray<string>, message: string): Tools<R> => ({
|
|
186
201
|
definitions: tools.definitions,
|
|
187
|
-
run: (call) => (names.includes(call.name) ? tools.run(call) : Effect.succeed(failed(message, "NotNow")))
|
|
202
|
+
run: (call) => (names.includes(call.name) ? tools.run(call) : Effect.succeed(failed(message, "NotNow"))),
|
|
203
|
+
// Refused like any other call it doesn't run.
|
|
204
|
+
needsApproval: () => Effect.succeed(false)
|
|
188
205
|
})
|