effect-assistant 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Assistant.d.ts +5 -11
- package/dist/Assistant.d.ts.map +1 -1
- package/dist/Assistant.js.map +1 -1
- package/dist/AssistantEvent.d.ts +66 -7
- package/dist/AssistantEvent.d.ts.map +1 -1
- package/dist/AssistantEvent.js +74 -9
- package/dist/AssistantEvent.js.map +1 -1
- package/dist/AssistantLoop.d.ts +4 -2
- package/dist/AssistantLoop.d.ts.map +1 -1
- package/dist/AssistantLoop.js +4 -2
- package/dist/AssistantLoop.js.map +1 -1
- package/dist/Block.d.ts +63 -1
- package/dist/Block.d.ts.map +1 -1
- package/dist/Block.js +63 -2
- package/dist/Block.js.map +1 -1
- package/dist/BlockKit.d.ts +25 -3
- package/dist/BlockKit.d.ts.map +1 -1
- package/dist/BlockKit.js +14 -12
- package/dist/BlockKit.js.map +1 -1
- package/dist/Compaction.d.ts +1 -1
- package/dist/Compaction.d.ts.map +1 -1
- package/dist/Compaction.js +11 -4
- package/dist/Compaction.js.map +1 -1
- package/dist/Skill.d.ts +85 -43
- package/dist/Skill.d.ts.map +1 -1
- package/dist/Skill.js +93 -44
- package/dist/Skill.js.map +1 -1
- package/dist/Specialist.d.ts +6 -9
- package/dist/Specialist.d.ts.map +1 -1
- package/dist/Specialist.js.map +1 -1
- package/dist/Timeline.d.ts +44 -6
- package/dist/Timeline.d.ts.map +1 -1
- package/dist/Timeline.js +87 -16
- package/dist/Timeline.js.map +1 -1
- package/dist/ToolApproval.d.ts +146 -0
- package/dist/ToolApproval.d.ts.map +1 -0
- package/dist/ToolApproval.js +201 -0
- package/dist/ToolApproval.js.map +1 -0
- package/dist/ToolDiscovery.d.ts +6 -4
- package/dist/ToolDiscovery.d.ts.map +1 -1
- package/dist/ToolDiscovery.js +5 -4
- package/dist/ToolDiscovery.js.map +1 -1
- package/dist/ToolResult.d.ts +56 -0
- package/dist/ToolResult.d.ts.map +1 -0
- package/dist/ToolResult.js +49 -0
- package/dist/ToolResult.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +6 -4
- package/dist/internal/chat.js +4 -1
- package/dist/internal/chat.js.map +1 -1
- package/dist/internal/history.js +28 -3
- package/dist/internal/history.js.map +1 -1
- package/dist/internal/loop.d.ts +10 -5
- package/dist/internal/loop.d.ts.map +1 -1
- package/dist/internal/loop.js +60 -21
- package/dist/internal/loop.js.map +1 -1
- package/dist/internal/specialists.js +9 -16
- package/dist/internal/specialists.js.map +1 -1
- package/dist/internal/toolkit.d.ts +11 -7
- package/dist/internal/toolkit.d.ts.map +1 -1
- package/dist/internal/toolkit.js +74 -21
- package/dist/internal/toolkit.js.map +1 -1
- package/dist/internal/tools.js +39 -6
- package/dist/internal/tools.js.map +1 -1
- package/dist/internal/turns.d.ts.map +1 -1
- package/dist/internal/turns.js +24 -16
- package/dist/internal/turns.js.map +1 -1
- package/package.json +1 -1
- package/src/Assistant.ts +7 -13
- package/src/AssistantEvent.ts +73 -8
- package/src/AssistantLoop.ts +4 -2
- package/src/Block.ts +82 -1
- package/src/BlockKit.ts +37 -4
- package/src/Compaction.ts +19 -2
- package/src/Skill.ts +134 -57
- package/src/Specialist.ts +8 -11
- package/src/Timeline.ts +120 -21
- package/src/ToolApproval.ts +249 -0
- package/src/ToolDiscovery.ts +8 -3
- package/src/ToolResult.ts +80 -0
- package/src/index.ts +10 -0
- package/src/internal/chat.ts +11 -1
- package/src/internal/history.ts +58 -3
- package/src/internal/loop.ts +138 -35
- package/src/internal/specialists.ts +7 -13
- package/src/internal/toolkit.ts +102 -35
- package/src/internal/tools.ts +81 -5
- package/src/internal/turns.ts +31 -10
- package/dist/internal/skills.js +0 -33
- package/dist/internal/skills.js.map +0 -1
- package/src/internal/skills.ts +0 -48
package/src/internal/loop.ts
CHANGED
|
@@ -18,10 +18,21 @@ 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
|
-
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,96 @@ 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
|
+
// 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
|
+
|
|
172
|
+
// Every `ToolStarted` is stored before any call runs; a call that waits for approval doesn't start.
|
|
116
173
|
const runCalls = <R, S, E, R2>(
|
|
117
174
|
tools: Toolkits.Tools<R>,
|
|
118
|
-
|
|
119
|
-
|
|
175
|
+
pending: ReadonlyArray<Pending>,
|
|
176
|
+
history: ReadonlyArray<Prompt.Message>,
|
|
177
|
+
then: (ran: Ran) => Stream.Stream<Step<never, S>, E, R2>
|
|
120
178
|
): 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
|
-
})
|
|
179
|
+
Stream.unwrap(
|
|
180
|
+
Effect.gen(function* () {
|
|
181
|
+
const waits = yield* Effect.forEach(pending, ({ call, approved }) =>
|
|
182
|
+
approved ? Effect.succeed(false) : tools.needsApproval(call, history)
|
|
131
183
|
)
|
|
132
|
-
|
|
184
|
+
|
|
185
|
+
const started = pending.flatMap(({ call }, i) =>
|
|
186
|
+
waits[i] === true ? [] : [value(AssistantEvent.ToolStarted.make({ callId: call.id, tool: call.name }))]
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return Stream.fromIterable(started).pipe(
|
|
190
|
+
Stream.concat(
|
|
191
|
+
Stream.unwrap(
|
|
192
|
+
Effect.gen(function* () {
|
|
193
|
+
const explained = pending.some(({ call }) => isUserMessage(call))
|
|
194
|
+
|
|
195
|
+
const done = yield* Effect.forEach(
|
|
196
|
+
pending,
|
|
197
|
+
({ call }, i): Effect.Effect<Event, never, R> =>
|
|
198
|
+
waits[i] !== true
|
|
199
|
+
? runCall(tools, call)
|
|
200
|
+
: explained
|
|
201
|
+
? awaitApproval(call)
|
|
202
|
+
: Effect.succeed(unexplained(call)),
|
|
203
|
+
{ concurrency: "unbounded" }
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return Stream.fromIterable(done.map(value)).pipe(
|
|
207
|
+
Stream.concat(
|
|
208
|
+
then({
|
|
209
|
+
failed: done.some(isFailure),
|
|
210
|
+
waiting: done.some((event) => Predicate.isTagged(event, "ApprovalRequested"))
|
|
211
|
+
})
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
})
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
})
|
|
133
219
|
)
|
|
134
220
|
|
|
135
|
-
const isFailure = (
|
|
221
|
+
const isFailure = (event: Event) => Predicate.isTagged(event, "ToolResult") && event.outcome === "failure"
|
|
136
222
|
|
|
137
223
|
/** @internal */
|
|
138
224
|
export type Step<A, S> = Data.TaggedEnum<{
|
|
@@ -192,19 +278,31 @@ export const step = <R, S, E2 = never, R2 = never>(
|
|
|
192
278
|
const instructions = Predicate.isString(options.instructions) ? { text: options.instructions } : options.instructions
|
|
193
279
|
|
|
194
280
|
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
281
|
|
|
199
282
|
return Stream.unwrap(
|
|
200
283
|
Effect.gen(function* () {
|
|
201
284
|
const { messages: history, throughSeq } = readHistory(yield* options.history)
|
|
202
285
|
// Calls a crash cut off, with no result: they run again, with the same ids, before the model goes on.
|
|
203
286
|
const cutOff = History.unansweredCalls(history)
|
|
287
|
+
const approvals = yield* TurnApprovals
|
|
288
|
+
|
|
289
|
+
// Calls the user approved: they run as the model made them, with the same ids, before the model goes on. Not in
|
|
290
|
+
// a loop without their tools.
|
|
291
|
+
const approved = Option.isNone(approvals)
|
|
292
|
+
? []
|
|
293
|
+
: (yield* approvals.value.approved).filter(
|
|
294
|
+
(call) =>
|
|
295
|
+
toolkit.definitions.some((tool) => tool.name === call.name) && !cutOff.some((cut) => cut.id === call.id)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
if (cutOff.length > 0 || approved.length > 0) {
|
|
299
|
+
const pending = [
|
|
300
|
+
...cutOff.map((call) => ({ call, approved: false })),
|
|
301
|
+
...approved.map((call) => ({ call, approved: true }))
|
|
302
|
+
]
|
|
204
303
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
after({ output: undefined, done: false, historyLength: history.length + cutOff.length })
|
|
304
|
+
return runCalls(toolkit, pending, history, ({ waiting }) =>
|
|
305
|
+
after({ output: undefined, done: waiting, historyLength: history.length + pending.length })
|
|
208
306
|
)
|
|
209
307
|
}
|
|
210
308
|
|
|
@@ -230,15 +328,20 @@ export const step = <R, S, E2 = never, R2 = never>(
|
|
|
230
328
|
const calls = toolCalls(output.parts)
|
|
231
329
|
const historyLength = history.length + Prompt.fromResponseParts(output.parts).content.length + calls.length
|
|
232
330
|
|
|
233
|
-
// A failed call doesn't end the turn: a message that failed never reached the user, so the model reads why.
|
|
331
|
+
// A failed call doesn't end the turn: a message that failed never reached the user, so the model reads why. A call
|
|
332
|
+
// that waits for approval does: the turn waits for the user.
|
|
234
333
|
return Stream.make(value(output)).pipe(
|
|
235
334
|
Stream.concat(
|
|
236
|
-
runCalls(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
335
|
+
runCalls(
|
|
336
|
+
toolkit,
|
|
337
|
+
calls.map((call) => ({ call, approved: false })),
|
|
338
|
+
history,
|
|
339
|
+
({ failed, waiting }) =>
|
|
340
|
+
after({
|
|
341
|
+
output,
|
|
342
|
+
done: calls.length === 0 || waiting || (!failed && calls.some(isTurnEnding)),
|
|
343
|
+
historyLength
|
|
344
|
+
})
|
|
242
345
|
)
|
|
243
346
|
)
|
|
244
347
|
)
|
|
@@ -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,22 +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
|
-
instructions: mapText(loop.instructions, (text) => Skill.withCatalog(text, skills)),
|
|
92
|
-
toolkit: Toolkits.merge(Skills.toTools(skills), own),
|
|
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),
|
|
93
88
|
history: current,
|
|
94
89
|
maxSteps: loop.maxSteps ?? 8
|
|
95
|
-
})
|
|
96
|
-
}
|
|
90
|
+
}).pipe(Stream.provideService(Loop.TurnApprovals, Option.none()))
|
|
97
91
|
}
|
|
98
92
|
|
|
99
93
|
// A run lives in memory, so there's nothing to run again after a crash: `ToolStarted` isn't kept.
|
|
100
94
|
const record = Effect.fnUntraced(function* (event: AssistantLoop.Event) {
|
|
101
|
-
if (Predicate.isTagged(event, "ToolStarted")) return
|
|
95
|
+
if (Predicate.isTagged(event, "ToolStarted") || Predicate.isTagged(event, "ApprovalRequested")) return
|
|
102
96
|
|
|
103
97
|
if (Predicate.isTagged(event, "ToolResult")) return yield* add([toolMessage(event)])
|
|
104
98
|
|
package/src/internal/toolkit.ts
CHANGED
|
@@ -1,21 +1,27 @@
|
|
|
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
|
-
import
|
|
5
|
+
import * 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"
|
|
10
12
|
import { AssistantToolCall } from "../AssistantToolCall.ts"
|
|
11
13
|
import * as ToolFailure from "../ToolFailure.ts"
|
|
14
|
+
import { ModelView } from "../ToolResult.ts"
|
|
12
15
|
import { toJson } from "./schema.ts"
|
|
16
|
+
import * as Uuid from "./uuid.ts"
|
|
13
17
|
|
|
14
18
|
/** @internal */
|
|
15
19
|
export interface CallResult {
|
|
16
20
|
readonly outcome: AssistantEvent.ToolOutcome
|
|
17
21
|
readonly result: Schema.Json
|
|
18
22
|
readonly reason?: string | undefined
|
|
23
|
+
readonly full?: Schema.Json | undefined
|
|
24
|
+
readonly resultId?: string | undefined
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
// Apps give the loop native toolkits; these are what the loop makes of them, merged with its own.
|
|
@@ -23,13 +29,18 @@ export interface CallResult {
|
|
|
23
29
|
export interface Tools<out R = never> {
|
|
24
30
|
readonly definitions: ReadonlyArray<Tool.Any>
|
|
25
31
|
readonly run: (call: AssistantEvent.ToolCallPart) => Effect.Effect<CallResult, never, R>
|
|
32
|
+
// Whether the call waits for the user's approval instead of running: its tool's `needsApproval`.
|
|
33
|
+
readonly needsApproval: (
|
|
34
|
+
call: AssistantEvent.ToolCallPart,
|
|
35
|
+
history: ReadonlyArray<Prompt.Message>
|
|
36
|
+
) => Effect.Effect<boolean, never, R>
|
|
26
37
|
}
|
|
27
38
|
|
|
28
39
|
/** @internal */
|
|
29
|
-
export type ToolServices<T extends Record<string, Tool.Any>> =
|
|
30
|
-
Tool.HandlerServices<T[keyof T]>,
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
export type ToolServices<T extends Record<string, Tool.Any>> =
|
|
41
|
+
| Exclude<Tool.HandlerServices<T[keyof T]>, AssistantToolCall>
|
|
42
|
+
// Already in the handlers' services; stated for the loop, which decodes params to ask `needsApproval`.
|
|
43
|
+
| T[keyof T]["parametersSchema"]["DecodingServices"]
|
|
33
44
|
|
|
34
45
|
const encodeFailure = Schema.encodeSync(ToolFailure.ToolFailure)
|
|
35
46
|
|
|
@@ -60,15 +71,32 @@ const hasTool = <T extends Record<string, Tool.Any>>(
|
|
|
60
71
|
name: PropertyKey
|
|
61
72
|
): name is keyof T => Object.hasOwn(toolkit.tools, name)
|
|
62
73
|
|
|
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
74
|
/** @internal */
|
|
66
75
|
export const needingApproval = (definitions: ReadonlyArray<Tool.Any>): ReadonlyArray<string> =>
|
|
67
76
|
definitions.flatMap((tool) => (tool.needsApproval === undefined || tool.needsApproval === false ? [] : [tool.name]))
|
|
68
77
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
78
|
+
// With noUncheckedIndexedAccess, an index doesn't narrow on `hasTool`.
|
|
79
|
+
const toolNamed = (toolkit: { readonly tools: Record<string, Tool.Any> }, name: string): Tool.Any | undefined =>
|
|
80
|
+
Object.hasOwn(toolkit.tools, name) ? toolkit.tools[name] : undefined
|
|
81
|
+
|
|
82
|
+
// Params that don't fit the tool don't wait: the call runs, and fails with why.
|
|
83
|
+
const needsApprovalNative = Effect.fnUntraced(function* <T extends Record<string, Tool.Any>>(
|
|
84
|
+
toolkit: { readonly tools: T },
|
|
85
|
+
call: AssistantEvent.ToolCallPart,
|
|
86
|
+
history: ReadonlyArray<Prompt.Message>
|
|
87
|
+
): Effect.fn.Return<boolean, never, ToolServices<T>> {
|
|
88
|
+
const tool = toolNamed(toolkit, call.name)
|
|
89
|
+
const needs = tool?.needsApproval
|
|
90
|
+
|
|
91
|
+
if (tool === undefined || needs === undefined || Predicate.isBoolean(needs)) return needs === true
|
|
92
|
+
|
|
93
|
+
return yield* Effect.gen(function* () {
|
|
94
|
+
const params = yield* Schema.decodeUnknownEffect(tool.parametersSchema)(call.params)
|
|
95
|
+
const decided = needs(params, { toolCallId: call.id, messages: history })
|
|
96
|
+
|
|
97
|
+
return Effect.isEffect(decided) ? yield* decided : decided
|
|
98
|
+
}).pipe(Effect.orElseSucceed(() => false))
|
|
99
|
+
})
|
|
72
100
|
|
|
73
101
|
// The toolkit decodes the params with the tool's schema before its handler sees them, and a call whose params don't
|
|
74
102
|
// fit fails with a validation error the model reads: the model's JSON is what `handle` takes.
|
|
@@ -95,12 +123,22 @@ export const runNative = Effect.fnUntraced(function* <T extends Record<string, T
|
|
|
95
123
|
const results = yield* toolkit.handle<keyof T>(name, params, call.id)
|
|
96
124
|
const last = yield* Stream.runLast(Stream.filter(results, (result) => !result.preliminary))
|
|
97
125
|
|
|
126
|
+
const view = Option.flatMap(Option.fromUndefinedOr(toolNamed(toolkit, call.name)), (tool) =>
|
|
127
|
+
Context.getOption(tool.annotations, ModelView)
|
|
128
|
+
)
|
|
129
|
+
|
|
98
130
|
return Option.match(last, {
|
|
99
131
|
onNone: () => failed(`${call.name} returned no result.`, "NoResult"),
|
|
100
|
-
onSome: (result): CallResult =>
|
|
101
|
-
result.isFailure
|
|
102
|
-
|
|
103
|
-
|
|
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
|
+
}
|
|
104
142
|
})
|
|
105
143
|
}).pipe(
|
|
106
144
|
Effect.catch((error) =>
|
|
@@ -117,7 +155,8 @@ export const fromWithHandler = <T extends Record<string, Tool.Any>>(
|
|
|
117
155
|
toolkit: Toolkit.WithHandler<T>
|
|
118
156
|
): Tools<ToolServices<T>> => ({
|
|
119
157
|
definitions: Object.values(toolkit.tools),
|
|
120
|
-
run: (call) => runNative(toolkit, call)
|
|
158
|
+
run: (call) => runNative(toolkit, call),
|
|
159
|
+
needsApproval: (call, history) => needsApprovalNative(toolkit, call, history)
|
|
121
160
|
})
|
|
122
161
|
|
|
123
162
|
/** @internal */
|
|
@@ -127,22 +166,8 @@ export const fromToolkit = <T extends Record<string, Tool.Any>>(
|
|
|
127
166
|
definitions: Object.values(toolkit.tools),
|
|
128
167
|
run: Effect.fnUntraced(function* (call) {
|
|
129
168
|
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
|
-
})
|
|
169
|
+
}),
|
|
170
|
+
needsApproval: (call, history) => needsApprovalNative(toolkit, call, history)
|
|
146
171
|
})
|
|
147
172
|
|
|
148
173
|
/** @internal */
|
|
@@ -153,7 +178,8 @@ export const fromToolkitOrEmpty = <T extends Record<string, Tool.Any>>(
|
|
|
153
178
|
/** @internal */
|
|
154
179
|
export const empty: Tools = {
|
|
155
180
|
definitions: [],
|
|
156
|
-
run: (call) => Effect.succeed(failed(`No tool named ${call.name}.`, "ToolNotFoundError"))
|
|
181
|
+
run: (call) => Effect.succeed(failed(`No tool named ${call.name}.`, "ToolNotFoundError")),
|
|
182
|
+
needsApproval: () => Effect.succeed(false)
|
|
157
183
|
}
|
|
158
184
|
|
|
159
185
|
/** @internal */
|
|
@@ -162,15 +188,54 @@ export const merge = <A, B>(self: Tools<A>, that: Tools<B>): Tools<A | B> => {
|
|
|
162
188
|
|
|
163
189
|
return {
|
|
164
190
|
definitions: [...self.definitions, ...that.definitions],
|
|
165
|
-
run: (call) => (inSelf(call) ? self.run(call) : that.run(call))
|
|
191
|
+
run: (call) => (inSelf(call) ? self.run(call) : that.run(call)),
|
|
192
|
+
needsApproval: (call, history) =>
|
|
193
|
+
inSelf(call) ? self.needsApproval(call, history) : that.needsApproval(call, history)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
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
|
|
166
217
|
}
|
|
167
218
|
}
|
|
168
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
|
+
|
|
169
233
|
// A turn's own services, for a loop the app writes.
|
|
170
234
|
/** @internal */
|
|
171
235
|
export const provide = <R>(tools: Tools<R>, context: Context.Context<R>): Tools => ({
|
|
172
236
|
definitions: tools.definitions,
|
|
173
|
-
run: (call) => Effect.provideContext(tools.run(call), context)
|
|
237
|
+
run: (call) => Effect.provideContext(tools.run(call), context),
|
|
238
|
+
needsApproval: (call, history) => Effect.provideContext(tools.needsApproval(call, history), context)
|
|
174
239
|
})
|
|
175
240
|
|
|
176
241
|
/** @internal */
|
|
@@ -184,5 +249,7 @@ export const duplicateNames = (tools: Tools<unknown>): ReadonlyArray<string> =>
|
|
|
184
249
|
/** @internal */
|
|
185
250
|
export const runOnly = <R>(tools: Tools<R>, names: ReadonlyArray<string>, message: string): Tools<R> => ({
|
|
186
251
|
definitions: tools.definitions,
|
|
187
|
-
run: (call) => (names.includes(call.name) ? tools.run(call) : Effect.succeed(failed(message, "NotNow")))
|
|
252
|
+
run: (call) => (names.includes(call.name) ? tools.run(call) : Effect.succeed(failed(message, "NotNow"))),
|
|
253
|
+
// Refused like any other call it doesn't run.
|
|
254
|
+
needsApproval: () => Effect.succeed(false)
|
|
188
255
|
})
|
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
|
|